10LOC
Web Platformintermediate

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 observer is created at module scope and reused for every image — creating an IntersectionObserver per 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.isIntersecting filters 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 real src, there's nothing left to watch, and leaving it observed means paying for intersection checks forever.
  • observeLazyImages takes an optional root so 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-src with a <noscript> fallback or a real src pointing 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 more data-src attribute 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 for IntersectionObserver when you need custom trigger distance or non-image targets.
  • ResizeObserver — same async, callback-driven shape, but for size changes instead of viewport intersection.