Home JS

IntersectionObserver: visibility-driven loading

IntersectionObserver fires a callback when a target element enters or leaves a viewport-relative root, with configurable thresholds. The right primitive for lazy-load, infinite-scroll, and sticky-state detection.

What the observer reports

IntersectionObserver watches one or more target elements and asynchronously notifies a callback when their intersection with a root element (or the viewport) crosses a configured threshold.

const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      entry.target.classList.add('in-view');
      io.unobserve(entry.target);
    }
  }
}, {
  root: null,                  // viewport
  rootMargin: '0px 0px -100px 0px',
  threshold: 0.1,              // 10% visible fires
});

document.querySelectorAll('.lazy').forEach((el) => io.observe(el));

Each entry’s intersectionRatio is a number from 0 to 1 indicating the fraction of the target’s bounding rect inside the root. isIntersecting is convenient shorthand for ratio > 0.

Why it replaces scroll listeners

Pre-2017, lazy-load and reveal-on-scroll were built on scroll event listeners that fired up to 60 times per second. The listener would getBoundingClientRect() every observed element, forcing layout. Real-world overhead on a moderate page reached 20–40 ms of main-thread jank per scroll second.

IntersectionObserver runs on the engine’s compositor thread and delivers entries asynchronously. Callbacks fire only at transitions, batched in a microtask. Typical overhead is below 1 ms per second of scrolling on the Chromium 2023 perf benchmark.

Constructor options

OptionTypeDefaultPurpose
rootElement or Document or nullviewportReference rect for intersection.
rootMarginCSS-margin string0pxGrow or shrink the root rect for off-screen pre-loading.
thresholdnumber or array0Single ratio or list of ratios at which to fire.

rootMargin accepts each side individually; the negative-margin trick lets you fire when an element is “still 100 px below the fold” — useful for reveal animations.

threshold: [0, 0.25, 0.5, 0.75, 1] fires at every quartile, useful for progress bars or animation timelines.

Common patterns

Lazy-load images

const io = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target;
    img.src = img.dataset.src;
    obs.unobserve(img);
  }
});
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));

A pure-CSS alternative — <img loading="lazy"> — exists since 2020 in all three engines. Use the native attribute first; reach for the observer only when CSS loading does not give enough control over the activation point.

Infinite scroll

const sentinel = document.querySelector('#more-sentinel');
new IntersectionObserver(async ([e]) => {
  if (!e.isIntersecting) return;
  const more = await fetch(`/items?cursor=${cursor}`).then((r) => r.json());
  appendItems(more);
  cursor = more.cursor;
}, { rootMargin: '500px' }).observe(sentinel);

A 500-pixel root margin pre-fetches the next page about 1 second before the user reaches it on a typical scroll velocity.

Sticky state detection

const io = new IntersectionObserver(
  ([entry]) => header.toggleAttribute('data-stuck', !entry.isIntersecting),
  { threshold: [1] },
);
io.observe(headerSentinel);

A 1-pixel sentinel above the sticky header yields the moment the header becomes stuck. About 80% of “is this header stuck?” use cases simplify to this pattern.

IntersectionObserver v2

IntersectionObserver v2 adds tracking of visual occlusion: whether the target is actually painted, or whether another element covers it (overlay, modal). Available only in Chromium 74+ (2019); not implemented in WebKit or Gecko as of 2025.

Common pitfalls

  • getBoundingClientRect() inside the callback. The engine has just given you the rect via entry.boundingClientRect; re-querying triggers layout. Use the entry data.
  • Long-running synchronous work in the callback. Defer to requestIdleCallback or break work into chunks. The callback runs on the main thread.
  • disconnect() not called. A long-lived observer with forgotten observers prevents element garbage collection.
  • Threshold of 0 then expecting “halfway visible”. 0 fires at the first pixel of intersection; you want 0.5.
  • Root must be an ancestor. A non-ancestor root causes the observer to never fire.

Cross-engine support

IntersectionObserver v1 reached interop in 2017 across Chromium, Firefox, and Safari (12.0). Pass-rate on the WPT subset is about 99% across the three engines as of the 2024 dashboard.

caniuse for intersection-observer reports about 99% global support. The v1 polyfill from W3C (maintained until 2021) is still occasionally used for legacy browser fleets.

Further reading