React Lazy Loading With Intersection Observer vs loading="lazy", Measured - NextGenBeing
Back to discoveries

React Lazy Loading With Intersection Observer vs loading="lazy", Measured

Build a reusable React lazy loading hook with IntersectionObserver, then see Chrome-measured requests, LCP and pop-in against native loading="lazy" and React.lazy.

Career & Industry Premium Content 12 min read
Bekzod Erkinov

Bekzod Erkinov

Apr 23, 2026 • 129 views
React Lazy Loading With Intersection Observer vs loading="lazy", Measured
Photo by Ofspace LLC on Unsplash
Size:
Height:
12 min read 4,890 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

React Lazy Loading With Intersection Observer vs loading="lazy", Measured

You have a React page with dozens of images (or one heavy widget) and you want them to load only when the user gets near them. Below is a roughly 25-line hook and component that does it, how it compares with loading="lazy" and React.lazy in headless Chrome on a local build, and the rootMargin, cleanup, SSR and testing problems I hit. The full benchmark harness is at the end, so you can rerun it.

Setup: how I measured

A Vite app (React 19.3.0, Vite 8.3.3) with 60 JPEGs of about 150 KB each, 400×250 CSS px, in a vertical list. A small Node server serves the build with a fixed 40 ms delay per image request. Playwright 1.63.0 drives the system Chrome 154.0.8037.98 (channel: 'chrome'; Playwright's own Chromium download timed out here). Each variant gets 5 fresh contexts at 1280×800, and I report the median LCP with min–max. The throttled runs use CDP emulation: 1.6 Mbps down, 150 ms latency. One machine, one synthetic page, HTTP/1.1: read the numbers as direction, not as your production site.

Variants: eager is a plain . **native** is `loading="lazy"` on images 2–60 with the first image left eager; it uses Chrome's own distance thresholds, which you can't tune. **IO hook** is the component below, with the `rootMargin` named in each row. All benchmark runs use the final `LazyImage` listed below, fallback included.

React lazy loading with IntersectionObserver: hook and component

// useInView.js
import { useEffect, useRef, useState } from 'react';

export function useInViewOnce({ rootMargin = '200px 0px', threshold = 0 } = {}) {
  const ref = useRef(null);
  const [seen, setSeen] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el || seen) return;
    // No IntersectionObserver (old browser, some test envs): load immediately.
    if (typeof IntersectionObserver === 'undefined') { setSeen(true); return; }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) { setSeen(true); io.disconnect(); }
      },
      { rootMargin, threshold }
    );
    io.observe(el);
    return () => io.disconnect(); // unmount, or options changed
  }, [seen, rootMargin, threshold]);

  return [ref, seen];
}
// LazyImage.jsx
import { useInViewOnce } from './useInView.js';

export function LazyImage({ src, alt, width, height, rootMargin, eager = false }) {
  const [ref, seen] = useInViewOnce({ rootMargin });
  return (
    
      {(eager || seen) && }
      
    
  );
}
  • Load once. The observer disconnects after the first hit; images don't need to unload on scroll-away. MDN distinguishes unobserve(el) (one target) from disconnect() (everything on that observer). Each hook instance owns its observer, so disconnect() fits.
  • A sized wrapper. The placeholder has the final width and height and the `` gets the same attributes, which web.dev recommends. measure.mjs below records layout shifts through a full scroll; the max CLS over 5 runs was 0.0000 in every variant, throttled or not.
  • The eager prop is what the table's "first image eager" row uses (eager={i === 0}): the `` renders at once, but the observer still runs, which is harmless for a single image.
  • The `` line is for SSR; see the SSR section.

Keep rootMargin a string constant and don't pass a fresh threshold array each render: both are in the effect's dependencies, and a new array recreates the observer every time.

Which one to use

  • Plain below-the-fold `` → loading="lazy" plus width and height.
  • You need a component, a data fetch, a video or an impression event, or a distance you control → the hook.
  • A large dependency (chart, editor) → React.lazy rendered behind the hook.
  • The LCP image → none of them. Plain eager ``.

For loading="lazy" support in the browsers you target, check a current support table: I couldn't pull version numbers out of web.dev's article text, so I don't quote any.

Requests and LCP

Requests and bytes are counted at load plus a wait (1.

Unlock Premium Content

You've read 30% of this article

What's in the full article

  • Complete step-by-step implementation guide
  • Working code examples you can copy-paste
  • Advanced techniques and pro tips
  • Common mistakes to avoid
  • Real-world examples and metrics

Join 10,000+ developers who love our premium content

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.