Lazy Loading
Defer loading of below-the-fold content, images, and heavy components until the user needs them.
Why
A page that loads everything upfront loads 3MB of images, a 200KB chart library, and a comments section that 80% of users never scroll to. Lazy loading flips this: the initial load includes only what is visible (the hero, the first article), and the rest loads as the user scrolls or interacts. This cuts the initial payload by 50-80%, which directly improves LCP and time-to-interactive. The browser's native loading="lazy" attribute makes image lazy loading a one-line change, and React.lazy / dynamic import() makes component lazy loading trivial. The payoff is immediate — the page loads in 1s instead of 3s, and the user who never scrolls to the comments never pays the cost of loading them.
When
For all below-the-fold images, for heavy components not needed on first paint (charts, editors, maps, video players), and for route-level code splitting. Not for above-the-fold content or the LCP element.
How
1. Lazy-load images with the native loading="lazy" attribute
- <img src="photo.jpg" loading="lazy" width="800" height="600" />
- Always include width and height to prevent layout shift (CLS)
- Set fetchpriority="high" on the LCP image to prioritize it
- Use a blurred placeholder (blur-up) for a better perceived experience
2. Lazy-load components with React.lazy and Suspense
- const Chart = lazy(() => import("./HeavyChart"));
- Wrap in <Suspense fallback={<Skeleton />}> to show a placeholder while loading
- Load on interaction (click) or on scroll into view (IntersectionObserver), not on mount
3. Lazy-load routes with dynamic imports
- Each route is a separate chunk, loaded only when navigated to
- Prefetch the next likely route on idle (requestIdleCallback) for instant navigation
- Use the framework's built-in code splitting (Next.js dynamic, Vite manualChunks)
4. Lazy-load data with IntersectionObserver
- Do not fetch comments until the comments section is scrolled into view
- const observer = new IntersectionObserver(callback); observer.observe(el);
- Load the next page of a list as the user approaches the bottom (infinite scroll)
5. Measure the impact
- Compare LCP and total blocked time before and after lazy loading
- Check the Network tab: the initial load should have fewer and smaller requests
- Verify that scrolling triggers the lazy loads (not a timer that loads everything anyway)Example
// Lazy images: native loading="lazy"
<img src="hero.jpg" fetchpriority="high" width="1200" height="600" alt="Hero" />
<img src="article-1.jpg" loading="lazy" width="800" height="500" alt="Article 1" />
<img src="article-2.jpg" loading="lazy" width="800" height="500" alt="Article 2" />
// Lazy component: load the chart only when the section is visible
import { lazy, Suspense, useState, useEffect, useRef } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));
function ChartSection() {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setVisible(true);
}, { rootMargin: "100px" }); // start loading 100px before visible
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div ref={ref}>
{visible && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
)}
</div>
);
}
// Lazy route: prefetch on idle for instant navigation
const Settings = lazy(() => import("./routes/Settings"));
// Prefetch when the user is likely to navigate (e.g., hover on the link)
function SettingsLink() {
return (
<Link
to="/settings"
onMouseEnter={() => requestIdleCallback(() => import("./routes/Settings"))}
>
Settings
</Link>
);
}Common Problems
- ⚠Lazy-loading the LCP element (the hero image) delays the most important content — the LCP should be eagerly loaded with fetchpriority="high", not lazy.
- ⚠Lazy-loading without a placeholder (no Suspense fallback, no skeleton) causes a blank flash or a layout jump when the content loads, increasing CLS and degrading the experience.
- ⚠Using a timer to trigger lazy loading (setTimeout 3s then load everything) defeats the purpose — the user waits 3s and then everything loads at once, same as eager loading but with an extra delay.
- ⚠Lazy-loading images without width/height attributes causes layout shift when they load — the content below jumps down, CLS spikes, and the user mis-clicks.
Best Practices
- ✓Eagerly load the LCP element (hero image, main heading) with fetchpriority="high" and lazy-load everything below the fold — the LCP is what the user sees first, and lazy-loading it defeats the purpose.
- ✓Always provide a Suspense fallback or skeleton that reserves the same space as the lazy content — this prevents layout shift and gives the user immediate feedback that content is loading.
- ✓Use IntersectionObserver with a rootMargin (e.g., 200px) to start loading before the element is fully visible — this makes the lazy load feel instant, as the content is ready by the time the user scrolls to it.
- ✓Prefetch the next likely route on idle (requestIdleCallback during link hover) so navigation feels instant — the chunk is already in the browser cache when the user clicks.