Implementing Lazy Loading of Components on a Website
Lazy loading is deferred loading of components, images, or modules until the moment they're actually needed by the user. Reduces initial bundle size, speeds up Time to Interactive, and lowers traffic on mobile devices.
Two Levels of Lazy Loading
Module level — component JavaScript code loads only on first render. Implemented via dynamic import().
Resource level — images, iframes, video load only when element enters visible area. Implemented via loading="lazy" attribute or IntersectionObserver.
React.lazy and Suspense
// Without lazy loading — all code in main bundle
import HeavyChart from '@/components/HeavyChart'
import DataTable from '@/components/DataTable'
// With lazy loading — separate chunks, load on demand
import { lazy, Suspense } from 'react'
const HeavyChart = lazy(() => import('@/components/HeavyChart'))
const DataTable = lazy(() => import('@/components/DataTable'))
function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={chartData} />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable rows={rows} />
</Suspense>
</div>
)
}
Suspense intercepts promise thrown by lazy component during loading and shows fallback. Once chunk loads — renders component.
Named Exports with Lazy
React.lazy works only with default export. For named exports, use wrapper:
// If component exported as named export
const BarChart = lazy(() =>
import('@/components/charts').then(module => ({
default: module.BarChart,
}))
)
Conditional Loading: Only When Visible
Loading heavy component immediately on page mount isn't always needed. If component is below fold, defer loading until scroll:
// hooks/useLazyComponent.ts
import { useState, useEffect, useRef } from 'react'
export function useLazyComponent(threshold = '200px') {
const ref = useRef<HTMLDivElement>(null)
const [shouldRender, setShouldRender] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldRender(true)
observer.disconnect()
}
},
{ rootMargin: threshold }
)
observer.observe(el)
return () => observer.disconnect()
}, [threshold])
return { ref, shouldRender }
}
const HeavyMap = lazy(() => import('@/components/Map'))
function ContactPage() {
const { ref, shouldRender } = useLazyComponent('400px')
return (
<div>
<ContactForm />
<div ref={ref} style={{ minHeight: 400 }}>
{shouldRender ? (
<Suspense fallback={<MapSkeleton />}>
<HeavyMap lat={53.9} lng={27.5} />
</Suspense>
) : (
<MapSkeleton />
)}
</div>
</div>
)
}
Lazy Loading Images
// Native lazy loading — supported by all modern browsers
function ProductCard({ product }: { product: Product }) {
return (
<div>
<img
src={product.image}
alt={product.title}
loading="lazy"
decoding="async"
width={400}
height={300}
/>
</div>
)
}
For finer control — custom hook with IntersectionObserver:
function useLazyImage(src: string) {
const imgRef = useRef<HTMLImageElement>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const img = imgRef.current
if (!img) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
img.src = src
img.onload = () => setLoaded(true)
observer.disconnect()
}
},
{ rootMargin: '100px' }
)
observer.observe(img)
return () => observer.disconnect()
}, [src])
return { imgRef, loaded }
}
Next.js: dynamic Import
import dynamic from 'next/dynamic'
// With custom loading state
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{
loading: () => <EditorSkeleton />,
ssr: false, // don't render on server — relevant for window-dependent components
}
)
// Only on condition
const AdminPanel = dynamic(() => import('@/components/AdminPanel'), { ssr: false })
function Page({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? <AdminPanel /> : <UserView />
}
Vite: Bundle Analysis
To understand what's worth lazy loading:
# Install plugin
npm install --save-dev rollup-plugin-visualizer
# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
visualizer({ open: true, gzipSize: true, filename: 'dist/stats.html' }),
],
}
After npm run build, interactive bundle map opens. Look for large dependencies: chart libraries, rich text editors, date pickers, map SDKs — first candidates for lazy loading.
Preload Critical Chunks
Chunks almost certainly needed can prefetch in idle time:
// Prefetch on link hover
function NavLink({ href, chunkImport, children }) {
const handleMouseEnter = () => {
chunkImport() // () => import('@/pages/About')
}
return <a href={href} onMouseEnter={handleMouseEnter}>{children}</a>
}
Timeline
Setting up React.lazy + Suspense for existing components — 0.5 day. Full bundle audit, prioritization, intersection-based loading with skeleton placeholders — 2–3 days depending on component count.







