IntersectionObserver for lazy-loaded images in 10 lines
Published August 25, 2026
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src ?? "";
img.removeAttribute("data-src");
obs.unobserve(img);
}
}, { rootMargin: "200px" });
export const observeLazyImages = (root: ParentNode = document) =>
root.querySelectorAll<HTMLImageElement>("img[data-src]").forEach((img) => observer.observe(img));What
observeLazyImages() finds every <img data-src="..."> under a root node and swaps data-src into src the moment the image is within 200px of the viewport, using a single shared IntersectionObserver.
Why it matters
The naive way to lazy-load images is a scroll listener that calls getBoundingClientRect() on every candidate image. That runs on the main thread on every scroll frame, forces a synchronous layout read each time, and gets worse as more images are on the page. IntersectionObserver moves that work off the main thread's critical path entirely — the browser computes intersection asynchronously and only calls back when something actually changes, so an idle page costs nothing no matter how many images it has.
Native <img loading="lazy"> covers the common case now, but it gives you no control over the trigger distance, no hook to run code when an image loads, and no way to lazy-load things that aren't <img> (background images, iframes, chart canvases). IntersectionObserver is the primitive underneath all of those.
How it works
- One
observeris created at module scope and reused for every image — creating anIntersectionObserverper element is a common but needless overhead; one observer can watch thousands of targets. rootMargin: "200px"grows the viewport's effective bounds by 200px on every side, so images start loading slightly before they're visible instead of popping in at the exact scroll edge.- Inside the callback,
entry.isIntersectingfilters out the exit notifications the observer also fires when a target scrolls back out of range — without that check you'd reload nothing, but you'd re-run the branch pointlessly. obs.unobserve(img)after loading is the important cleanup: once an image has its realsrc, there's nothing left to watch, and leaving it observed means paying for intersection checks forever.observeLazyImagestakes an optionalrootso it also works for images that get added inside a dynamically-rendered subtree, not just the whole document.
Gotchas
- This assumes JS is available. Pair
data-srcwith a<noscript>fallback or a realsrcpointing at a low-res placeholder so images aren't just missing when JS fails to load. - If you call
observeLazyImages()again after inserting new images into the DOM, already-loaded images are safely skipped (no moredata-srcattribute to match), but you still need to call it again — the observer doesn't watch elements it was never told about.
Related
loading="lazy"— the zero-JS native equivalent for plain<img>/<iframe>; reach forIntersectionObserverwhen you need custom trigger distance or non-image targets.ResizeObserver— same async, callback-driven shape, but for size changes instead of viewport intersection.