Does Lazy Loading Actually Improve Performance?#

It depends entirely on where you apply it. Lazy loading above-the-fold images actively harms performance. When the browser's preload scanner encounters <img loading="lazy">, it cannot immediately issue the request — it must first evaluate whether the image falls within the viewport. This introduces a decision delay. Google's own data shows that lazy-loading an LCP image can add 500ms or more to the LCP timing.

Below the fold, the calculus flips. Users may never scroll to the bottom of a long article or a paginated category grid. Downloading images they'll never see wastes bandwidth on their side and server egress on yours. For article pages and product listing pages, lazy loading typically cuts initial image bandwidth by 30–70%.

Lazy loading decision flow
Lazy loading decision flow

Native Lazy Loading: Use It Unless You Can't#

html
1<img src="blog-image.avif" loading="lazy" alt="Article diagram" />

That's the whole API. Browser support passed 96% in 2024. No JavaScript, no polyfill, no library.

The Loading Distance Threshold#

Browsers begin loading lazy images before they enter the viewport — the exact distance varies by implementation and is not specified in the standard:

  • Chrome: roughly 500–1250px, adjusted dynamically based on effective connection type. On fast connections, it preloads more aggressively.
  • Firefox: approximately 500px, relatively consistent across connection types.
  • Safari: approximately one viewport height, the most conservative of the three.

The thresholds are generous enough that images typically finish loading before they scroll into view, even with fast scrolling. The browser isn't waiting until the last pixel — it's anticipating.

What Native Lazy Loading Won't Do#

  • No custom threshold. You cannot tell the browser "wait until this image is 200px from the viewport."
  • No load-complete callback. No event fires when the image finishes loading. You cannot trigger a fade-in animation or update surrounding UI state.
  • No visibility tracking. You cannot know whether a lazy image was ever actually viewed.
  • iframe support exists but behaves differently. loading="lazy" works on iframes, but the loading distance threshold may differ from images.

Intersection Observer: When You Need Control#

The Intersection Observer API gives you an explicit callback when an element's visibility relative to a root element (or the viewport) crosses a threshold:

js
1function createLazyImage(img) {
2 const observer = new IntersectionObserver(
3 (entries) => {
4 entries.forEach((entry) => {
5 if (entry.isIntersecting) {
6 const el = entry.target;
7 
8 if (el.dataset.src) {
9 el.src = el.dataset.src;
10 }
11 if (el.dataset.srcset) {
12 el.srcset = el.dataset.srcset;
13 }
14 
15 el.onload = () => el.classList.add('loaded');
16 
17 observer.unobserve(el);
18 }
19 });
20 },
21 {
22 rootMargin: '200px 0px',
23 threshold: 0.01,
24 }
25 );
26 
27 observer.observe(img);
28}
29 
30document.querySelectorAll('img[data-src]').forEach(createLazyImage);

rootMargin: The Preload Lever#

rootMargin expands or shrinks the observer's effective viewport rectangle. Think of it as growing an invisible margin around the viewport where intersections start counting:
js
1// Conservative — image only loads once it's actually in view.
2// Users may see it loading.
3{ rootMargin: '0px' }
4 
5// Balanced — preloads when the image is 300px away.
6// Good default for most content pages.
7{ rootMargin: '300px 0px' }
8 
9// Aggressive — preloads 800px ahead.
10// Works for long articles where users scroll quickly.
11// Trade-off: more bandwidth used for images that may never be seen.
12{ rootMargin: '800px 0px' }
The value is a CSS margin string: 'top right bottom left'. For vertical scrolling, only the top and bottom values matter.

Pairing with content-visibility#

content-visibility: auto tells the browser it can skip rendering work for off-screen elements. It's not lazy loading, but it complements it:
css
1.article-section {
2 content-visibility: auto;
3 contain-intrinsic-size: auto 500px;
4}
The contain-intrinsic-size property provides an estimated height so the scrollbar doesn't jump around as sections get laid out. Without it, content-visibility can create a disorienting scroll experience where the page "grows" unpredictably.

Placeholder Strategies#

The most common lazy-loading UX problem is images popping into existence abruptly. A low-quality placeholder (LQIP) smooths the transition.

CSS Blur Technique#

css
1.lazy-img {
2 filter: blur(20px);
3 transform: scale(1.1); /* blur creates transparent edges, scale hides them */
4 transition: filter 400ms ease, transform 400ms ease;
5}
6 
7.lazy-img.loaded {
8 filter: blur(0);
9 transform: scale(1);
10}
html
1<img
2 src="data:image/webp;base64,UklGRiIAAABXRUJQVlA4..."
3 data-src="real-image.avif"
4 class="lazy-img"
5/>

The inline base64 is a 16px-wide version of the image, blurred and encoded at extremely low quality. It's typically 300–600 bytes. The browser paints it instantly, then the real image replaces it with a blur-to-sharp CSS transition.

Dominant Color Placeholder#

For product photos on white backgrounds, a 16px thumbnail is overkill. Extract the average color and use a solid fill:

js
1import { getAverageColor } from 'fast-average-color-node';
2 
3async function getPlaceholderColor(imagePath) {
4 const color = await getAverageColor(imagePath);
5 return color.hex; // e.g., '#f5f0eb'
6}

Solid-color placeholders are effectively free (a CSS background value) and completely eliminate the "image pop-in" effect.

Techniques at a Glance#

Lazy loading techniques comparison
Lazy loading techniques comparison

Framework Implementations#

React#

tsx
1import { useEffect, useRef, useState } from 'react';
2 
3function LazyImage({ src, alt, ...props }: { src: string; alt: string }) {
4 const imgRef = useRef<HTMLImageElement>(null);
5 const [loaded, setLoaded] = useState(false);
6 
7 useEffect(() => {
8 const el = imgRef.current;
9 if (!el) return;
10 
11 const observer = new IntersectionObserver(
12 ([entry]) => {
13 if (entry.isIntersecting) {
14 el.src = src;
15 observer.unobserve(el);
16 }
17 },
18 { rootMargin: '300px' }
19 );
20 
21 observer.observe(el);
22 return () => observer.disconnect();
23 }, [src]);
24 
25 return (
26 <img
27 ref={imgRef}
28 alt={alt}
29 {...props}
30 onLoad={() => setLoaded(true)}
31 className={loaded ? 'loaded' : 'loading'}
32 />
33 );
34}

What Should Never Be Lazy-Loaded#

These must load immediately, every time:

  1. The LCP image. Almost always the hero or cover image. Lazy-loading it can add 500ms+ to LCP.
  2. The site logo. Usually small, needed on every page, often the first <img> in the DOM.
  3. Above-the-fold icon sprites. Uncommon in modern setups, but if you use them, they need to be available immediately.
  4. CSS background-image on above-the-fold elements. Browsers do not lazy-load CSS backgrounds. They load when the CSS rule matches, which means immediately if the element is in the viewport.
A simple decision rule: if the image lives in the first ~800px of the page, use loading="eager" (or just omit the attribute — eager is the default).

Verifying Your Lazy Loading Setup#

Open DevTools → Network, check "Disable cache," and reload. Look at the Initiator column for image requests:
  • index.html or parser — the image was found directly in the HTML and loaded immediately. Not lazy-loaded.
  • A script name like lazy-load.js — the image was loaded by your JavaScript. Lazy loading is working.
  • The timing in the waterfall view should show body images loading after the initial page resources, often in clusters as you scroll.

Summary#

ScenarioApproach
Simplest case, no special needsloading="lazy"
Need fade-in animationIntersection Observer + CSS transition
Need custom preload distanceIntersection Observer + rootMargin
Need placeholders16px LQIP base64 + blur transition
Reusable across a frameworkLazyImage component wrapping observer logic
The most important thing is knowing when not to lazy-load. Setting loading="lazy" as a blanket default on every <img> is worse than not using it at all.