Lazy-load images with IntersectionObserver
Canonical URL: https://devexamples.com/javascript/lazy-load-images-with-intersectionobserver/
The problem
A gallery page ships thirty photographs and a reader on a phone may never scroll past the fifth, yet every image referenced in the markup is fetched at load and competes with the CSS and script that make the first screen work. Assumes a modern browser, one pending address per element held in a data attribute, and known width and height so nothing reflows. The native attribute is a genuine alternative and is compared below; it is set aside here because the swap must also keep a placeholder box in the layout and begin at a distance the design chooses.
Short answer
Register each pending image with one IntersectionObserver whose rootMargin band starts the swap before the image is needed, copy data-src and data-srcset onto the element when entry.isIntersecting is true, unobserve that element at once, and reach for the loading attribute when deferring the fetch is the only requirement.
Thirty img elements in the markup mean thirty requests, and the browser cannot tell the fifth one from the first. IntersectionObserver supplies the missing fact: whether an element is currently near what the reader can see. Used for a source swap it turns below-the-fold images into requests that start only when the reader is about to need them, and it does that with one observer rather than one scroll handler. The first fragment below is the pending markup this pattern drives; the second is the same card written with the platform’s own attribute, kept in view so the two can be compared rather than assumed.
<ul class="gallery">
<li class="gallery-card">
<img
class="gallery-img"
src="placeholder-lagoon-32x18.avif"
data-src="lagoon-800.avif"
data-srcset="lagoon-400.avif 400w, lagoon-800.avif 800w"
sizes="(min-width: 60rem) 33rem, 100vw"
width="800"
height="450"
alt="Turquoise lagoon between basalt cliffs"
>
<noscript>
<img
src="lagoon-800.avif"
width="800"
height="450"
alt="Turquoise lagoon between basalt cliffs"
>
</noscript>
</li>
</ul><li class="gallery-card">
<img
src="lagoon-800.avif"
srcset="lagoon-400.avif 400w, lagoon-800.avif 800w"
sizes="(min-width: 60rem) 33rem, 100vw"
width="800"
height="450"
loading="lazy"
decoding="async"
alt="Turquoise lagoon between basalt cliffs"
>
</li>const pendingImages = document.querySelectorAll('img[data-src]');
const options = {
root: null, // the viewport, not an inner scroll box
rootMargin: '200px 0px', // start the swap one thumb-flick early
threshold: 0 // one painted pixel is enough to matter
};
function loadNow(img) {
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
delete img.dataset.src;
delete img.dataset.srcset;
}
function swapWhenVisible(entries, obs) {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue; // left the band again before we acted
}
loadNow(entry.target);
obs.unobserve(entry.target); // one image, one swap, no further entries
}
}
// typeof on an undeclared global is safe; constructing on it is not.
const canObserve = typeof IntersectionObserver === 'function';
const observer = canObserve ? new IntersectionObserver(swapWhenVisible, options) : null;
for (const img of pendingImages) {
if (observer) {
observer.observe(img);
} else {
loadNow(img); // no observer: fetch now rather than show nothing
}
}Explanation
The observer’s job is to answer one question about many elements at once, and the answer arrives as a list of entries rather than as an event per element. Each entry carries the element in entry.target and the current visibility state in entry.isIntersecting, which is why the loop must read that flag before acting: the callback also reports elements that have left the root, and treating any callback as a load instruction would swap sources for images the reader just scrolled past. threshold: 0 makes the condition as cheap as possible — one pixel of the image inside the expanded root is enough — because a source swap has no visual moment to align to; only the fetch matters. The 200px bottom and top band in rootMargin is what makes the swap look instant: the request starts while the image is still off-screen, so decode is usually finished before the reader can see the box.
Two decisions do the reliability work. obs.unobserve(entry.target) runs in the same pass as the swap, so a loaded image never produces another entry and the observer’s target list shrinks to the images still pending; without it the callback keeps firing for elements that are already done, which is harmless in principle and measurable in practice on a long page. The support check is written as typeof IntersectionObserver === 'function' rather than a window property read because new IntersectionObserver(...) would throw in an engine that lacks it, and the fallback then never runs. The fallback loads everything at once, which is the correct degraded behaviour: a page that ignores the deferral is slower, a page that hides its images is broken. The noscript element in the markup covers the case where script cannot run: while scripting is available its children are never parsed into elements, so that second image issues no request and costs nothing until the reader really has no script.
loading="lazy" is the honest comparison, and it is usually the better tool. It needs no script, no data attribute, no observer, it handles a full srcset on its own, and it works on images added by any code path, while the browser decides the deferral distance per engine and connection — which is a feature for readers and an inconvenience for anyone wanting a tuned band. The observer earns its place where the requirement is more than deferral: keeping a placeholder element in the layout until the real file is ready, starting the fetch at a distance the design picks instead of the engine’s, changing several attributes as one coordinated swap, or running a side effect at the same instant such as starting an animation. If the requirement is only “do not fetch this yet”, write the attribute and skip this code.
The boundary worth testing before shipping is the first screen. Images already intersecting at load are reported in the observer’s first delivery, which happens after layout rather than during parsing, so an observer-driven hero is strictly slower than a plain img — keep the largest above-the-fold image unconditionally loaded and observe only what is below it. Deferral also depends on the element having a box: an image inside a display: none tab reports isIntersecting: false forever, so its request waits until that panel is opened and laid out. And because the observer holds the element rather than the address, nodes created after this script ran must be registered explicitly or they stay placeholders indefinitely.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
data-src | string URL on the img element | Yes | — | The real image address, copied onto src by the callback the first time the element intersects. |
data-srcset | string candidate list | No | — | Responsive candidates to copy alongside src; omit it when one file serves every viewport. |
rootMargin | CSS-style margin string | No | 200px 0px in the code above | How far outside the viewport the swap is triggered, so decode and layout finish before the image is seen. |
threshold | number between 0 and 1 | No | 0 | Share of the image that must be inside the expanded root before a callback is delivered; zero is one painted pixel. |
Expected output
Below-the-fold cards paint their small placeholder immediately, the real file is requested when a card comes within 200px of the viewport, and an image the reader never scrolls near never appears in the network log at all.
Usage notes
- Keep width and height on every pending image, including the placeholder swap, so the reserved box is the same size before and after and the reader's scroll position cannot jump.
- Call observe() again for images injected after the first pass; an observer only reports elements that were registered with it, so freshly rendered cards stay unloaded.
- Tune rootMargin against a real connection rather than by taste: it trades bytes transferred up front against the chance the reader sees an empty box.
Common mistakes
- Leaving the element observed after the swap, so every scroll that re-enters the viewport delivers a callback for an image that is already loaded.
- Treating any callback as an instruction to load instead of reading entry.isIntersecting; the same callback also reports elements that just left the band.
- Loading everything in the fallback branch only after the observer throws, which leaves the page blank in the engines that need the fallback most.
Caveats
- This pattern defers element images only. A CSS background image is fetched when its style rule is used, and no observer changes that, so move such artwork into an img element first.
- An element inside a display: none subtree has no box, so it never intersects and its image stays unloaded until that subtree is shown and laid out.
- Deferring the largest image above the fold hurts the metric it was meant to protect; the observer swaps sources a frame or two after layout, which is later than the parser would have started the fetch.
Follow-up
Follow-up
Keep a table header visible with sticky positioningA sticky header shortens the visible area, so the space each deferred image reserves is what keeps the viewport from jumping.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Build a card grid that reflows without media queriesBoth keep a growing grid usable: one decides how wide its columns get, this one decides when its images arrive.
Same task or topic
Reveal an element the first time it enters the viewportSolves the Observe element visibility task
Same task or topic
Add a skip link that lands on the main regionShares the DOM Events topic
References
- IntersectionObserver: IntersectionObserver() constructor(opens in a new tab) — MDN Web Docs. Root, rootMargin and threshold options plus how entries are batched into the callback.
- HTMLImageElement: loading property(opens in a new tab) — MDN Web Docs. Semantics of the native lazy-loading attribute and when a browser overrides it.
- Lazy loading - Performance(opens in a new tab) — MDN Web Docs. What the platform already defers on its own and where script is still required.
Source page: https://devexamples.com/javascript/lazy-load-images-with-intersectionobserver/