Blog

Blog · Performance & Optimization

Lazy Loading Best Practices 2026: Images, Components & Routes

How to lazy-load the right way — native image/iframe lazy loading, never deferring the LCP image, route and component splitting, IntersectionObserver, prefetch-on-intent and handling chunk errors — each with why and a code example.

XCODX Team · 6 min read

Lazy loading defers work until it’s actually needed — offscreen images, below-the-fold iframes, routes and heavy components — so the initial load is faster and lighter. Done carelessly it backfires: lazy-loading the wrong thing (your hero image) makes performance *worse*. Here are lazy loading best practices for 2026, each with the reasoning and a code example.

1. Use native image lazy loading

The browser can defer offscreen images with a single attribute — no JavaScript, no library. loading="lazy" holds the download until the image nears the viewport, saving bandwidth and speeding the initial load. It’s supported across all modern browsers in 2026.

<img src="gallery-9.jpg" loading="lazy"
     width="600" height="400" alt="…">

2. Never lazy-load the LCP or above-the-fold image

This is the mistake that hurts most. Adding loading="lazy" to the hero/LCP image makes the browser deprioritize the very element your LCP score is based on, often adding a second or more. Keep above-the-fold images eager, and go further — add fetchpriority="high" to the LCP image.

<!-- ❌ deprioritizes your LCP element -->
<img src="hero.jpg" loading="lazy">

<!-- ✅ eager + prioritized -->
<img src="hero.avif" fetchpriority="high" width="1200" height="675" alt="…">

3. Lazy-load offscreen iframes

Third-party iframes (maps, videos, embeds) are heavy — they pull in their own scripts and requests. Native loading="lazy" on the iframe defers all of that until the user scrolls near it. For YouTube specifically, a click-to-load "facade" (a thumbnail that swaps in the iframe on click) saves even more.

<iframe src="https://maps.example/embed" loading="lazy"
        width="600" height="400" title="Map"></iframe>

4. Split routes and lazy-load them

Route-based lazy loading is the highest-impact split: each page becomes its own chunk loaded on navigation, so first load ships only the landing route. In React, lazy() + Suspense; most frameworks (Next.js, Nuxt, SvelteKit) do route splitting automatically.

const Reports = lazy(() => import("./routes/Reports"));
<Suspense fallback={<PageSkeleton />}>
  <Route path="/reports" element={<Reports />} />
</Suspense>

5. Lazy-load heavy components on demand

A charting library, rich text editor, map or PDF viewer can each outweigh your whole app shell. Defer it with a dynamic import so it loads only when the user opens that feature — not on every page load. Pair it with a loading state so the UI stays honest.

const Chart = lazy(() => import("./Chart"));
function Panel({ open }) {
  return open ? (
    <Suspense fallback={<Spinner />}><Chart /></Suspense>
  ) : null;
}

6. Always provide a fallback or skeleton

A lazy boundary means a brief wait while the chunk loads; showing nothing looks broken and a spinner that pops in/out causes layout shift. Render a skeleton that matches the final layout’s size so the load feels fast and nothing jumps (protecting CLS).

<Suspense fallback={<div className="skeleton" style={{ height: 320 }} />}>
  <HeavyWidget />
</Suspense>

7. Handle chunk load errors

Lazy chunks are fetched over the network and can fail — offline, or (commonly) because a new deploy replaced the hashed file the old page is asking for, throwing a ChunkLoadError. Catch it in an error boundary and offer a reload, which fetches the current chunks.

class ChunkBoundary extends React.Component {
  componentDidCatch(err) {
    if (/ChunkLoadError|Loading chunk/.test(err.message)) location.reload();
  }
  render() { return this.props.children; }
}

8. Prefetch on intent so lazy still feels instant

Lazy loading trades a smaller initial load for a small delay when the feature is used. Hide that delay by prefetching the chunk the moment intent appears — on link hover, focus, or when a trigger scrolls into view — so it’s already cached by the time of the click.

link.addEventListener("mouseenter", () => {
  import("./routes/Reports");   // warm the chunk on hover
}, { once: true });

9. Use IntersectionObserver for custom lazy loading

When native lazy loading isn’t enough — background images, custom widgets, analytics that should fire when a section is seen — IntersectionObserver tells you when an element enters the viewport, efficiently and off the main thread (unlike scroll listeners). Load the resource in the callback, then unobserve.

const io = new IntersectionObserver((entries) => {
  for (const e of entries) if (e.isIntersecting) {
    e.target.src = e.target.dataset.src;
    io.unobserve(e.target);
  }
});
document.querySelectorAll("[data-src]").forEach((el) => io.observe(el));

10. Load a little ahead with rootMargin

Loading exactly at the viewport edge means users see a blank placeholder before the content appears. A positive rootMargin starts the load before the element is visible, so it’s ready as it scrolls in. Native lazy loading already does this; for custom observers, tune rootMargin to trade earliness against wasted loads.

new IntersectionObserver(cb, {
  rootMargin: "200px 0px",   // start loading 200px early
});

11. Don't over-split into a request waterfall

Too many tiny lazy chunks create a waterfall of sequential requests, each with its own latency — slower than one modestly larger download. Split at meaningful boundaries (routes, genuinely heavy features), not around every small component. Measure: if a "split" adds a round-trip for a few KB, inline it.

// ❌ over-split: 20 tiny chunks, 20 round-trips
// ✅ split at route + heavy-feature boundaries only
const Editor = lazy(() => import("./Editor"));  // big, worth splitting

12. Skip offscreen rendering with content-visibility

For long pages and feeds, content-visibility: auto is "lazy rendering": the browser skips layout and paint for offscreen sections until they scroll near the viewport — web.dev measured large initial-render gains. Always pair it with contain-intrinsic-size so skipped sections reserve height and don’t cause layout shift.

.feed-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 300px;   /* reserve estimated height */
}

13. Combine lazy loading with responsive images

Lazy loading and responsive images stack: defer the offscreen image *and* let the browser pick the smallest file for the device. Together they minimize both when and how much you download. Keep width/height so deferred images still reserve space and don’t shift the page as they load in.

<img loading="lazy"
     srcset="p-400.avif 400w, p-800.avif 800w" sizes="(max-width:600px) 100vw, 50vw"
     src="p-800.avif" width="800" height="600" alt="…">

14. Measure the impact

Lazy loading should reduce initial requests and bytes and improve LCP — verify it does. Compare the Network panel and Lighthouse before and after, and watch that you didn’t accidentally lazy-load something above the fold (a classic LCP regression). Confirm real-user LCP in the field, not just the lab.

// check LCP didn't regress from an over-eager lazy attribute
import { onLCP } from "web-vitals";
onLCP((m) => console.log("LCP", m.value, m.attribution?.element));

Lazy loading checklist

  • Lazy-load offscreen images and iframes with native loading="lazy".
  • Never lazy-load the LCP/above-the-fold image — keep it eager + fetchpriority="high".
  • Split and lazy-load routes and heavy components; always show a matching skeleton.
  • Handle ChunkLoadError in an error boundary; prefetch chunks on intent.
  • Use IntersectionObserver (+ rootMargin) for custom lazy loading.
  • Don’t over-split into a request waterfall; use content-visibility for long feeds.
  • Combine with responsive images; measure LCP before and after.

Frequently asked questions

What should I never lazy-load?
Your LCP element and anything above the fold — especially the hero image. Adding loading="lazy" to the largest visible element makes the browser deprioritize it and directly worsens your Largest Contentful Paint. Keep above-the-fold media eager, and add fetchpriority="high" to the LCP image.
Is native lazy loading better than a JavaScript library?
For images and iframes, yes — loading="lazy" is built in, needs no JavaScript, and is supported across all modern browsers in 2026. Reach for IntersectionObserver only for cases native loading doesn’t cover, like background images, custom widgets, or firing logic when a section becomes visible.
How do I handle a ChunkLoadError?
It usually means a new deploy replaced the hashed chunk the current page is requesting. Catch it in a React error boundary (match ChunkLoadError/Loading chunk) and reload the page so it fetches the current chunks. Prefetching on intent and keeping old chunks around for a grace period also reduce these errors.
Does lazy loading improve Core Web Vitals?
It can improve LCP and reduce total bytes by deferring offscreen work — but only if you don’t defer visible content. Lazy-load below-the-fold images, iframes and routes; keep the LCP image eager. Always measure before/after, since an over-eager lazy attribute is a common cause of an LCP regression.

Try lazy loading, Suspense and IntersectionObserver live in XCODX Studio. See also the Code Splitting Guide, Image Optimization Guide and How to Improve Core Web Vitals.