DEV Community

Cover image for Your scroll listener fires on every pixel. `IntersectionObserver` fires when visibility actually changes.
Parsa Jiravand
Parsa Jiravand

Posted on

Your scroll listener fires on every pixel. `IntersectionObserver` fires when visibility actually changes.

Here is a pattern I still see in real codebases:

window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect();
  if (rect.top < window.innerHeight && rect.bottom >= 0) {
    loadImage(element);
  }
});
Enter fullscreen mode Exit fullscreen mode

It works. It's also firing on every pixel of scroll movement — potentially 60 callbacks per second on a smooth display — and each one calls getBoundingClientRect(), which forces a synchronous layout recalculation. You probably added throttling or requestAnimationFrame wrapping to soften the blow. You're still doing layout work on the main thread to answer a question the browser already knows the answer to.

IntersectionObserver has been baseline since 2019. It's still underused.

What it does

IntersectionObserver watches one or more elements and fires a callback only when their visibility status changes — entering the viewport, leaving it, or crossing a fraction threshold you specify. The geometry calculations happen off the main thread; you get called when something meaningful happens.

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log(entry.target, 'entered the viewport');
    }
  });
});

observer.observe(document.querySelector('.card'));
Enter fullscreen mode Exit fullscreen mode

entries is an array of IntersectionObserverEntry objects — one per observed element that changed state in this tick. entry.isIntersecting is true when the element entered, false when it left.

Lazy loading an image

The classic use case. No scroll event, no position math:

const lazyImages = document.querySelectorAll('img[data-src]');

const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;

    const img = entry.target;
    img.src = img.dataset.src;
    imageObserver.unobserve(img); // done watching this one
  });
});

lazyImages.forEach(img => imageObserver.observe(img));
Enter fullscreen mode Exit fullscreen mode

The observer fires once when each image enters the viewport. After setting src, unobserve drops the element from the watch list. No cleanup loop, no residual listener, no memory leak.

threshold and rootMargin

Two options control exactly when the callback fires.

threshold is a number between 0 and 1 — the fraction of the element that must be visible. The default is 0, which fires the moment a single pixel enters the viewport. threshold: 1 waits until the element is fully visible. Pass an array to fire at multiple milestones:

const observer = new IntersectionObserver(callback, {
  threshold: [0, 0.25, 0.5, 0.75, 1],
});
Enter fullscreen mode Exit fullscreen mode

entry.intersectionRatio tells you the actual fraction at the moment of the callback — useful for video autoplay, progressive reveal animations, or impression-depth analytics.

rootMargin works like a CSS margin around the viewport boundary, shifting where "visible" begins:

const observer = new IntersectionObserver(callback, {
  rootMargin: '0px 0px 300px 0px', // fire 300px before entering from the bottom
});
Enter fullscreen mode Exit fullscreen mode

This is the correct tool for preloading. Trigger the network request 300px before the element reaches the viewport, so it's ready when the user gets there — without any manual distance math.

Analytics impression tracking

Knowing whether a user actually saw a piece of content, versus just scrolling past it, is a common product requirement. With a scroll listener you're sampling at intervals and accepting inaccuracy. With IntersectionObserver:

const impressionObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;
    analytics.track('impression', {
      id: entry.target.dataset.id,
      ratio: entry.intersectionRatio,
    });
    impressionObserver.unobserve(entry.target);
  });
}, { threshold: 0.5 }); // at least half the element must be visible

document.querySelectorAll('[data-track]').forEach(el => impressionObserver.observe(el));
Enter fullscreen mode Exit fullscreen mode

The threshold: 0.5 option means a fast scroll past an element doesn't count — the user had to slow down enough for half of it to enter view. One observer, zero scroll event listeners, accurate data.

Cleanup in components

In React, Vue, or any component that mounts and unmounts, disconnect the observer on teardown:

useEffect(() => {
  const observer = new IntersectionObserver(callback);
  observer.observe(ref.current);

  return () => observer.disconnect(); // stops watching all elements
}, []);
Enter fullscreen mode Exit fullscreen mode

disconnect() removes all observed elements at once. If you have a single long-lived observer watching multiple elements and only want to stop tracking one, unobserve(element) does that without affecting the rest.

The takeaway

IntersectionObserver answers the question "is this element visible?" without running on every scroll tick. The main thread is not involved — the browser handles the geometry internally and calls your callback only when the answer changes.

Search your codebase for scroll event listeners that contain getBoundingClientRect. Every one of them is a candidate for replacement. The handler runs less often, the layout thrash disappears, and the intent is clearer in the code: you're watching for visibility, and the observer is the right name for that.


Thanks for reading! Let's stay connected:

Top comments (0)