Bekzod Erkinov
Listen to Article
Loading...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) fromdisconnect()(everything on that observer). Each hook instance owns its observer, sodisconnect()fits. - A sized wrapper. The placeholder has the final width and height and the `` gets the same attributes, which web.dev recommends.
measure.mjsbelow records layout shifts through a full scroll; the max CLS over 5 runs was 0.0000 in every variant, throttled or not. - The
eagerprop 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"pluswidthandheight. - 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.lazyrendered 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
Don't have an account? Start your free trial
Join 10,000+ developers who love our premium content
Keep reading
React vs Angular: Which Framework to Choose in 2026
26 min · 212 views
AI WorkflowsPostgreSQL Hidden Features for Better Performance: Battle-Tested Optimizations from Production
24 min · 205 views
AI WorkflowsMulti-Tenant SaaS Architecture with Laravel and React: Battle-Tested Patterns from Production
33 min · 188 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
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