Lazy Load Images with IntersectionObserver for Faster Page Loads
A robust, CLS-safe lazy-loading pattern that pairs native loading="lazy"
with an IntersectionObserver enhancer, progressive formats, and a tiny blur-up placeholder.
When to Lazy-Load (and When Not To)
Lazy loading is perfect for below-the-fold assets, galleries, blog images, and long lists. Do not lazy-load the hero/LCP image, critical logos, or above-the-fold media. Those should be eager with fetchpriority="high"
.
- Lazy: article images below first viewport, offscreen cards, testimonials.
- Eager: hero/LCP, brand logo in header, first viewport background art.
Semantic Markup Scaffold (Picture + Blur-Up)
Reserve space with width
/height
(prevents CLS) and use <picture>
for AVIF/WebP fallbacks. The real sources live in
data-*
attributes until observed.
<figure class="space-y-2">
<picture class="lazy-picture" data-sizes="(min-width:1024px) 800px, (min-width:640px) 600px, 100vw">
<!-- Low-res placeholder (tiny blurred image / dominant color) -->
<img
class="lazy-img blur-[8px] scale-[1.02] transition-all duration-500 ease-out bg-gray-100 dark:bg-gray-800"
src="/images/blog/placeholder-20w.webp"
alt="Team collaborating at a whiteboard"
width="1200" height="800" loading="lazy" decoding="async" />
<!-- Data sources activated by JS -->
<template>
<source type="image/avif"
data-srcset="/images/blog/photo-640.avif 640w, /images/blog/photo-800.avif 800w, /images/blog/photo-1200.avif 1200w">
<source type="image/webp"
data-srcset="/images/blog/photo-640.webp 640w, /images/blog/photo-800.webp 800w, /images/blog/photo-1200.webp 1200w">
<img
data-src="/images/blog/photo-800.jpg"
data-srcset="/images/blog/photo-640.jpg 640w, /images/blog/photo-800.jpg 800w, /images/blog/photo-1200.jpg 1200w"
data-sizes="(min-width:1024px) 800px, (min-width:640px) 600px, 100vw"
alt="Team collaborating at a whiteboard"
width="1200" height="800" decoding="async" />
</template>
</picture>
<figcaption class="text-sm text-gray-600 dark:text-gray-400">Blur-up placeholder swaps to full-res when observed.</figcaption>
<!-- Noscript fallback ensures images still render without JS -->
<noscript>
<picture>
<source type="image/avif" srcset="/images/blog/photo-640.avif 640w, /images/blog/photo-800.avif 800w, /images/blog/photo-1200.avif 1200w">
<source type="image/webp" srcset="/images/blog/photo-640.webp 640w, /images/blog/photo-800.webp 800w, /images/blog/photo-1200.webp 1200w">
<img src="/images/blog/photo-800.jpg"
srcset="/images/blog/photo-640.jpg 640w, /images/blog/photo-800.jpg 800w, /images/blog/photo-1200.jpg 1200w"
sizes="(min-width:1024px) 800px, (min-width:640px) 600px, 100vw"
alt="Team collaborating at a whiteboard" width="1200" height="800" loading="lazy" decoding="async">
</picture>
</noscript>
</figure>
The initial inline <img>
acts as a tiny placeholder. JS swaps in the full sources, then removes the blur once decoded.
Tiny IntersectionObserver Loader (Decode, Then Swap)
This script activates sources only when near viewport, decodes before reveal, then gracefully removes blur. It also respects prefers-reduced-data
if you wire a toggle.
<script>
// lazy-images.js
(() => {
const pictures = [...document.querySelectorAll('.lazy-picture')];
if (!pictures.length || !('IntersectionObserver' in window)) {
// Graceful fallback: just hydrate all sources immediately
pictures.forEach(hydratePicture);
return;
}
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const pic = entry.target;
hydratePicture(pic);
io.unobserve(pic);
}
}, { rootMargin: '200px 0px', threshold: 0.01 });
pictures.forEach(p => io.observe(p));
function hydratePicture(pic){
// Pull template content (sources + final <img>)
const tpl = pic.querySelector('template');
if (!tpl) return;
const frag = tpl.content.cloneNode(true);
// Move data-src/srcset/sizes => real attributes
frag.querySelectorAll('[data-srcset]').forEach(el => {
el.setAttribute('srcset', el.getAttribute('data-srcset'));
el.removeAttribute('data-srcset');
});
frag.querySelectorAll('[data-sizes]').forEach(el => {
el.setAttribute('sizes', el.getAttribute('data-sizes'));
el.removeAttribute('data-sizes');
});
const finalImg = frag.querySelector('[data-src]');
if (!finalImg) return;
finalImg.setAttribute('src', finalImg.getAttribute('data-src'));
finalImg.removeAttribute('data-src');
// Insert new <source> and final <img> before placeholder
const placeholder = pic.querySelector('.lazy-img');
pic.insertBefore(frag, placeholder);
// Decode, then swap visibility
finalImg.decode?.().catch(() => {}).then(() => {
// Remove blur+scale on placeholder, then replace it
placeholder?.classList?.remove('blur-[8px]', 'scale-[1.02]');
// After transition, remove placeholder
setTimeout(() => placeholder?.remove(), 250);
});
}
})();
</script>
We set a generous rootMargin
so images are ready just before they scroll into view.
Tailwind-First Variant (Utility Classes)
If you prefer utilities, the placeholder can be a single class. The rest of the logic stays the same.
<img class="lazy-img w-full h-auto rounded-lg bg-gray-100 dark:bg-gray-800 blur-[8px] scale-[1.02] transition-all duration-500" ...>
Don’t Lazy-Load LCP: Eager + Fetch Priority
Your hero image should ship like this—eager, with explicit width
/height
, and accurate sizes
:
<img
src="/images/hero-1200.webp"
srcset="/images/hero-800.webp 800w, /images/hero-1200.webp 1200w, /images/hero-1600.webp 1600w"
sizes="(min-width:1024px) 960px, 100vw"
alt="Product hero on gradient background"
width="1600" height="900"
loading="eager" fetchpriority="high" decoding="async" />
Build-Time Automation (Node + sharp)
Generate AVIF/WebP/JPEG widths at build time and keep byte budgets tight.
// scripts/make-images.js
import sharp from "sharp"; import fg from "fast-glob"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path";
const sizes = [640, 800, 1200];
for (const file of await fg("src/images/**/*.{jpg,jpeg,png}")) {
const base = file.replace(/^src\/images\//, "").replace(/\.(jpe?g|png)$/i, "");
for (const w of sizes) {
const outDir = `dist/images/${base.split("/").slice(0,-1).join("/")}`;
mkdirSync(outDir, { recursive: true });
await sharp(file).resize({ width: w }).avif({ quality: 50 }).toFile(`dist/images/${base}-${w}.avif`);
await sharp(file).resize({ width: w }).webp({ quality: 78 }).toFile(`dist/images/${base}-${w}.webp`);
await sharp(file).resize({ width: w }).jpeg({ quality: 74, mozjpeg: true }).toFile(`dist/images/${base}-${w}.jpg`);
}
}
Performance & Accessibility Guardrails
- CLS-safe: Always include
width
/height
(oraspect-ratio
) to reserve space. - Decoding: Use
decoding="async"
and calldecode()
before swapping. - Formats: Prefer AVIF/WebP with JPEG fallback inside
<picture>
. - Sizes: An accurate
sizes
string saves bandwidth and improves quality. - Lazy threshold: Use
rootMargin
(e.g., 200–400px) for prefetching just in time. - Noscript: Always include a non-JS fallback for crawlers and users with JS disabled.
Common Pitfalls (and Fixes)
- Images pop in late: Increase
rootMargin
, ensure CDN caching, reduce file sizes. - Blurry on desktop: Add a larger width (e.g., 1600w) and fix
sizes
string. - Layout shifts: Add correct intrinsic
width
/height
or setaspect-ratio
. - Safari quirks: Keep
img
as the last child in<picture>
; provide MP4/AVIF/WebP in the right order.
QA Checklist
- ✅ Hero/LCP is not lazy-loaded; has
fetchpriority="high"
. - ✅ All lazy images reserve space (no CLS).
- ✅ AVIF/WebP + JPEG fallback delivered via
<picture>
. - ✅ IntersectionObserver prefetches ~200–400px ahead.
- ✅ Blur-up transitions out after
decode()
. - ✅ Noscript fallback present and valid.
FAQs
Is native loading="lazy"
enough?
It’s a great baseline. The IO pattern adds earlier prefetch timing, blur-up control, and decode-aware swaps—smoother UX with minimal JS.
Will lazy loading hurt SEO?
No—content is still in the DOM, and we provide <noscript>
fallbacks. Don’t lazy-load critical LCP/hero imagery.
What if IntersectionObserver isn’t supported?
The script hydrates all images immediately (graceful fallback). Support is excellent in modern browsers.
Key Takeaways
- Don’t lazy-load LCP. Lazy everything else with IO + blur-up.
- Reserve space with width/height; serve AVIF/WebP with accurate
sizes
. - Decode before reveal; remove blur smoothly; include noscript fallback.
- Automate derivatives at build time and enforce byte budgets.