ESC

Type to search the knowledge base.

IntersectionObserver

Observe element visibility asynchronously — lazy images, infinite scroll, ad viewability, without scroll listener jank.

intermediate3 min read
  • javascript
  • intersectionobserver
  • performance
  • lazy-load

Scroll listeners that call getBoundingClientRect on every frame jank. IntersectionObserver asks the browser to tell you when a target’s visibility against a root crosses thresholds — async, batched, and cheap enough for lazy-loading hundreds of images.

Minimal observer

const io = new IntersectionObserver(
  (entries, observer) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    }
  },
  {
    root: null, // viewport
    rootMargin: '200px 0px', // preload before visible
    threshold: 0.01,
  },
);

document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));

Entry fields worth knowing

entry.isIntersecting; // boolean convenience
entry.intersectionRatio; // 0–1 visible fraction
entry.boundingClientRect;
entry.intersectionRect;
entry.rootBounds;
entry.target;
entry.time;

threshold can be a number or array:

new IntersectionObserver(cb, {
  threshold: [0, 0.25, 0.5, 0.75, 1],
});

Callbacks fire when the ratio crosses those marks (not continuously).

root and rootMargin

const scroller = document.querySelector('.scrollport');

const io = new IntersectionObserver(cb, {
  root: scroller, // default null = viewport
  rootMargin: '0px 0px -40% 0px', // shrink root (sticky header math)
  threshold: 0.5,
});

rootMargin grows/shrinks the root’s intersection box — like CSS margin syntax. Positive values expand (prefetch); negative shrink (must be more inside).

Infinite scroll

const sentinel = document.querySelector('#infinite-sentinel');

const io = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || loading) return;
  loading = true;
  const page = await fetchNext();
  appendRows(page);
  loading = false;
});

io.observe(sentinel);

Disconnect or unobserve when no more pages exist.

Analytics / viewability

const seen = new WeakSet();
const io = new IntersectionObserver(
  (entries) => {
    for (const e of entries) {
      if (e.intersectionRatio >= 0.5 && !seen.has(e.target)) {
        seen.add(e.target);
        track('impression', e.target.dataset.id);
      }
    }
  },
  { threshold: 0.5 },
);

Cleanup

io.unobserve(el);
io.disconnect(); // all targets

In React, observe in useEffect and disconnect on unmount.

Versus scroll handlers

Scroll + rects IntersectionObserver
Runs on main thread often Browser optimizes
Easy to thrash layout No forced layout in your callback ideally
Precise pixel control Threshold-based

You can still read rects inside the callback, but keep work light — schedule heavy jobs with requestIdleCallback / rAF.

Interview answer

“IntersectionObserver reports when a target crosses visibility thresholds relative to a root (usually the viewport). I use it for lazy images, infinite scroll sentinels, and impressions. rootMargin preloads early; threshold sets how much must be visible. It’s preferable to scroll listeners with getBoundingClientRect for performance.”

Multiple targets and root bounds

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    e.target.classList.toggle('in-view', e.isIntersecting);
  }
});

document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
// one observer, many targets — cheaper than one observer per node

entry.rootBounds is null when the root isn’t a element (viewport) in some cases — don’t assume it’s always a DOMRect. For horizontal carousels, set root to the scroller element. Disconnect observers in tests between cases to avoid cross-test callbacks.

Zero-size targets

If the target has height: 0 or is display: none, it may never intersect. Sentinels for infinite scroll need non-zero size (even 1px) inside the scrollport. For elements inside transformed ancestors, intersection still works against the root; don’t mix with manual rect math unless necessary.

sentinel.style.height = '1px';
sentinel.setAttribute('aria-hidden', 'true');

Further reading

Related guides