What is lazy loading and how would you implement it?
Lazy loading defers loading a resource until it's actually needed — usually 'about to be visible.' For images: <img loading='lazy'>. For iframes: <iframe loading='lazy'>. For JS: dynamic import() + React.lazy/Suspense. For arbitrary components: IntersectionObserver to mount when the placeholder enters the viewport. Reduces initial bytes, JS parse cost, and main-thread work; trade-off is the loading state when it finally needs to appear.
Lazy loading = don't load something until it's needed. The trigger is usually scroll-into-view, hover, or click. Goal: cut initial bytes and main-thread work; pay the cost only for resources users actually engage with.
1. Native image lazy-loading
<img src="below-fold.jpg" loading="lazy" alt="" width="800" height="600">- Zero JS. Browser decides when to fetch based on viewport distance.
- Set
width+height(or use CSS aspect-ratio) so the page doesn't shift when the image loads. - Don't lazy-load the LCP image — that's a known anti-pattern that tanks LCP. Use
loading="eager"(default) +fetchpriority="high"for it.
2. Native iframe lazy-loading
<iframe src="https://www.youtube.com/embed/…" loading="lazy"></iframe>YouTube embeds, ads, maps in the sidebar — huge wins because iframes can load their own megabytes of JS.
3. JS code splitting (lazy modules)
button.onclick = async () => {
const { default: heavy } = await import('./heavy-tool.js');
heavy.run();
};The bundler emits heavy-tool.js as a separate chunk fetched on demand.
4. React.lazy + Suspense
const Modal = React.lazy(() => import('./Modal'));
function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
{open && (
<Suspense fallback={<Spinner />}>
<Modal />
</Suspense>
)}
</>
);
}React.lazy works on default exports. For named exports, use lazy(() => import('./X').then(m => ({ default: m.Foo }))).
In Next.js, prefer next/dynamic — supports SSR control:
import dynamic from 'next/dynamic';
const Map = dynamic(() => import('./Map'), { ssr: false, loading: () => <Spinner /> });5. IntersectionObserver for arbitrary lazy components
For components without code-split needs but heavy render cost:
function LazyVisible({ children, height = 200 }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
setVisible(true);
io.disconnect();
}
}, { rootMargin: '200px' });
io.observe(ref.current);
return () => io.disconnect();
}, []);
return <div ref={ref} style={{ minHeight: height }}>{visible ? children : null}</div>;
}rootMargin: '200px' starts loading before it's strictly visible so it's ready by the time the user reaches it.
6. Lazy data fetching
React Query / SWR's useQuery only runs when the component mounts. Wrap heavy data needs in a deferred component, and the fetch deferral comes for free.
When NOT to lazy-load
- LCP image / hero font — those are critical.
- Above-the-fold content — visible immediately, no benefit.
- Tiny components — overhead of split + suspense exceeds gain.
- Sequential dependencies that would create waterfalls (lazy → load → lazy → load).
Layout shift trap
Lazy-loaded images without reserved dimensions cause CLS (cumulative layout shift) when they arrive. Always:
- Set explicit
widthandheightattributes, or - Use
aspect-ratioCSS, or - Provide a sized skeleton placeholder.
Measurement
- Lighthouse: "Defer offscreen images," "Reduce unused JavaScript."
- DevTools Coverage tab: see how much JS was actually executed vs loaded.
- Real metrics: LCP, INP, total bytes transferred.
Mental model
Lazy loading is the opposite of preload. Preload: "I need this earlier than the browser would discover it." Lazy: "I don't need this yet — wait." Use both in concert: preload the LCP image and the critical font; lazy-load everything below the fold and behind interactions.
Follow-up questions
- •Why is lazy-loading the LCP image bad?
- •How do you prevent layout shift when images lazy-load?
- •What's the difference between React.lazy and next/dynamic?
- •When does IntersectionObserver-based lazy loading make sense over native?
Common mistakes
- •Lazy-loading the LCP image — tanks the LCP score.
- •No width/height/aspect-ratio on lazy images — causes CLS.
- •Splitting tiny components — overhead > payload.
- •Lazy-loading critical interactions — UI feels stuck.
- •Forgetting to disconnect IntersectionObserver — memory leak on long-lived pages.
- •Nesting Suspense too deep — many tiny loaders flash on screen during navigation.
Performance considerations
- •Lazy loading is one of the cheapest, highest-ROI optimizations: a few lines of code, zero runtime overhead, often 30–60% smaller initial transfer on content-heavy pages. Pair with code splitting for compound wins. The only counter-rule: never lazy the LCP element.
Edge cases
- •Print rendering forces all lazy images to load — browsers handle this.
- •Save-Data signals — opt out of speculative prefetches.
- •Anchor navigation to #section deep on the page — browser forces lazy images above the anchor to load.
- •Lazy iframes don't fire onload until they're actually loaded — UX states tied to onload need adjustment.
- •SSR: lazy components render their fallback during SSR; client hydrates and loads — keep fallback visually stable to avoid hydration mismatch.
Real-world examples
- •Most major news sites use native loading='lazy' on article images.
- •YouTube embed lite (lite-youtube-embed) lazy-loads the iframe and player JS.
- •Pinterest's masonry grid uses IntersectionObserver-based lazy loading aggressively.
- •React docs use React.lazy + Suspense for the code playground.