Lazy Load Images with IntersectionObserver for Faster Page Loads
A robust, CLS-safe lazy-loading pattern that pairs native loading="lazy"
with IntersectionObserver, modern formats, and blur-up placeholders. This guide explains when to lazy-load, how to implement it safely, and why it matters for Core Web Vitals, accessibility, and long-term SEO.
When to Lazy-Load (and When Not To)
Lazy loading is meant for below-the-fold assets (any bulky media you don't see on first load) in areas like galleries, libraries, and long-form blog content. It conserves bandwidth, boosts Core Web Vitals, and improves load speed. That does not mean that everything should be lazy-loaded, however — your LCP image (hero, first viewport graphic) must be eagerly loaded with fetchpriority="high"
and loading="eager"
.
The web.dev lazy loading guide stresses the balance between these levers and how their misuse can delay LCP, lower rankings, and frustrate users. Like with any tool which has the ability to influence page load, think of lazy loading as a scalpel instead of a sledgehammer.
- Lazy-load: images in long blog posts, product galleries, testimonials, infinite scroll feeds.
- Eager-load: hero/LCP, header logos, above-the-fold visuals.
For broader strategy, see balancing function and style and content strategy for service sites. Both guides reinforce that performance is in service of conversions.
Semantic Markup Scaffold
Proper lazy loading starts with semantic, CLS-safe markup, which will generally be the case. Before worrying about anything else, set width
and height
to reserve space — this alone solves most problems with assets. Use <picture>
for AVIF/WebP with JPEG fallback.
Keep actual sources in data-*
attributes until observed. For more on HTML structure, see HTML/CSS structure best practices.
<figure>
<picture class="lazy-picture" data-sizes="(min-width:1024px) 800px, 100vw">
<img
class="lazy-img blur-[8px] scale-[1.02] transition-all duration-500 ease-out"
src="/images/blog/placeholder-20w.webp"
alt="Team collaborating at a whiteboard"
width="1200" height="800" loading="lazy" decoding="async">
<template>
<source type="image/avif" data-srcset="/images/photo-800.avif 800w, /images/photo-1200.avif 1200w">
<source type="image/webp" data-srcset="/images/photo-800.webp 800w, /images/photo-1200.webp 1200w">
<img
data-src="/images/photo-800.jpg"
data-srcset="/images/photo-800.jpg 800w, /images/photo-1200.jpg 1200w"
data-sizes="(min-width:1024px) 800px, 100vw"
alt="Team collaborating at a whiteboard"
width="1200" height="800" decoding="async">
</template>
</picture>
</figure>
This ensures accessibility and SEO are preserved. Crawlers see real content in the DOM. See MDN’s lazy loading docs for browser-level support.
IntersectionObserver Script
The IntersectionObserver API detects when elements approach the viewport without relying on scroll handlers. Our script decodes images before swapping, ensuring smooth transitions. For motion-safe animation, revisit JavaScript scroll reveal effect.
<script>
(() => {
const pictures = [...document.querySelectorAll('.lazy-picture')];
if (!pictures.length || !('IntersectionObserver' in window)) {
pictures.forEach(hydratePicture);
return;
}
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
hydratePicture(entry.target);
io.unobserve(entry.target);
}
}, { rootMargin: '300px 0px', threshold: 0.01 });
pictures.forEach(p => io.observe(p));
function hydratePicture(pic){
const tpl = pic.querySelector('template');
if (!tpl) return;
const frag = tpl.content.cloneNode(true);
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) {
finalImg.setAttribute('src', finalImg.getAttribute('data-src'));
finalImg.removeAttribute('data-src');
}
const placeholder = pic.querySelector('.lazy-img');
pic.insertBefore(frag, placeholder);
finalImg.decode?.().catch(() => {}).then(() => {
placeholder?.classList?.remove('blur-[8px]', 'scale-[1.02]');
setTimeout(() => placeholder?.remove(), 250);
});
}
})();
</script>
Performance & Accessibility Guardrails
- Always reserve space with
width
/height
oraspect-ratio
to prevent CLS. - Use
decoding="async"
and calldecode()
before swapping to avoid flashes. - Prefer AVIF/WebP with JPEG fallback. See W3C image resource guide.
- Fine-tune
rootMargin
(200–400px) to prefetch just in time. - Always provide a
<noscript>
fallback for non-JS users and bots.
For more on performance tuning, see how to improve site speed and lazy vs. eager loading.
Troubleshooting Common Issues
- Images appear late: Increase
rootMargin
or reduce file sizes. Validate with Chrome Performance panel. - Blurry on desktop: Add larger resolutions (e.g., 1600w) and correct
sizes
. - Layout shifts: Ensure
width
/height
attributes are correct. - Safari quirks: Place
<img>
as the last child inside<picture>
.
For related dev patterns, see file structure for speed & scale.
QA Checklist Before You Ship
- Hero/LCP is not lazy-loaded; uses
fetchpriority="high"
. - All lazy images reserve intrinsic space (no CLS).
- AVIF/WebP with JPEG fallback provided in
<picture>
. - IntersectionObserver prefetches 200–400px before viewport.
- Blur-up transitions out smoothly after
decode()
. - Noscript fallback included and valid.
FAQs
Is native loading="lazy"
enough?
Native lazy loading is a strong baseline to work from, but the IntersectionObserver pattern adds prefetch timing, blur-up control, and decode-aware swaps — leading to smoother UX. For context, see mobile performance best practices.
Will lazy loading hurt SEO?
Not even a little — content remains in the DOM and noscript fallbacks are provided so search engines can crawl everything. See technical SEO for hand-coded sites.
What if IntersectionObserver isn’t supported?
Then the script hydrates all images immediately, but support is excellent across modern browsers so that is unlikely. The A11y Project checklist recommends graceful fallbacks like this.
Key Takeaways
- Do not lazy-load LCP. Lazy everything else with IO + blur-up.
- Always reserve space with width/height or aspect-ratio.
- Decode images before reveal; remove blur smoothly.
- Automate format generation at build time to control budgets.
- Accessibility and SEO are preserved when content exists in HTML.