← All writing
Craft · · 7 min

IntersectionObserver

On why the scroll handler was always wrong rather than merely slow, and the parameter that does most of the interesting work.

JavaScript Performance

Here’s a function I have written, in some form, on at least a dozen projects:

the old way
window.addEventListener('scroll', () => {
  items.forEach(el => {
    const box = el.getBoundingClientRect();
    if (box.top < window.innerHeight) el.classList.add('is-visible');
  });
});
JavaScript

Everybody has written that. It works. And it’s not merely inefficient, it’s the wrong shape, in a way that no amount of throttling fixes.

Why it was always wrong

Two problems, and the second is the real one.

Scroll events fire a great many times per second, on the main thread, and your handler runs between the browser wanting to paint a frame and actually painting it. So anything expensive in there is directly subtracted from the frame budget, and the symptom is scrolling that feels slightly sticky in a way people notice without being able to name.

But the killer is getBoundingClientRect(). It returns the element’s current position, which means the browser has to guarantee that layout is up to date before it can answer, which means it stops and recalculates. If your loop reads a position and then writes a class, and then reads the next position, you’ve forced a layout recalculation per element per frame. That’s layout thrashing, and it’s the reason these handlers get catastrophically slow with the number of elements rather than gently slower.

Throttling makes it happen less often. Batching the reads and writes makes each round cheaper. Neither changes the fact that you’re polling for an answer the browser already has.

You were polling, sixty times a second, for a fact the browser already knew and would have been glad to tell you.

What it does instead

You describe what you’re interested in, once, and the browser calls you when it happens. The work is done off the main thread, batched, with no forced layout, and it costs roughly nothing when nothing is crossing.

observer.js
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.classList.add('is-visible');
    io.unobserve(entry.target);        // one-shot: stop watching once it's fired
  }
}, { rootMargin: '0px 0px -10% 0px', threshold: 0 });

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

Same effect as the scroll handler, no scroll handler.

What it’s actually for

Infinite scroll and load-more. Put an empty sentinel element after the last item and observe it. When it comes into view, fetch the next page. This is the cleanest use of the API and the code is about six lines. (Whether you should build infinite scroll at all is a separate argument, and the answer is usually no for anything with a footer in it.)

Reveal-on-scroll animations. As above. And immediately: check prefers-reduced-motion before you attach anything, because a page full of things sliding in as you scroll is precisely the pattern I wrote about in 2018 that makes some people ill. Fade rather than travel, or don’t animate at all.

Impression tracking. Did this element genuinely appear on screen, and for how long. Useful for working out whether the thing everyone argues about in meetings is ever actually seen. Worth noting that this is also exactly how advertising viewability measurement works, so if you’re building it, be clear with yourself about which of those you’re doing.

Sticky header state. “Has the user scrolled past the hero” is a question people answer with a scroll listener and a magic number. Observe a one-pixel sentinel at the top of the page instead, and toggle the class when it leaves. No numbers, no listener, and it keeps working when the hero’s height changes.

Scrollspy. Highlighting the current section in a table of contents. This is the fiddliest of the five and the one where the configuration actually matters.

Lazy loading, which used to be the headline use and now mostly isn’t, because there’s an attribute for images that does it natively and better. Which is a nice small illustration of how this goes: a general-purpose primitive arrives, everyone builds the same thing with it, and then the platform absorbs the common case.

rootMargin is the interesting one

The parameter that does the real work and that people skip past.

It expands or shrinks the box that counts as “in view,” using margin-like syntax. Positive values fire early, before the element is really visible, which is what you want for prefetching. Negative values fire late, when the element is properly on screen.

rootmargin.js
{ rootMargin: '200px' }                  // fire 200px before it arrives (prefetch)
{ rootMargin: '-40% 0px -40% 0px' }      // only the middle 20% of the screen counts
                                         // ← this is the scrollspy answer
JavaScript

That second one is the trick for a table of contents. Shrink the root to a band across the middle of the viewport, and then “the current section” is simply whatever is intersecting that band. All the horrible arithmetic about which heading is nearest the top disappears.

Units have to be pixels or percentages. Not em, not rem, which I have got wrong twice and which fails silently.

The gotchas

The callback fires immediately when you observe. Once per element, with the current state. If your callback assumes it only runs on a change, you’ll get a surprise on page load, and if half your elements are already on screen you’ll get several.

Unobserve one-shot things. If a reveal animation only needs to happen once, stop watching after it fires. Otherwise you’re keeping a live observation on every element on the page forever, which is cheap but not free.

Thresholds are crossings, not states. threshold: [0, 0.25, 0.5, 1] gives you a callback each time the ratio crosses one of those values, in either direction. Useful for progress indicators, noisy if you only wanted “is it there.”

What it can’t do

It’s not a scroll API and it can’t be made into one. It doesn’t tell you scroll position, direction, or speed, and if you need those you still need a scroll listener, though you can usually infer direction by comparing the entry’s rectangle to the last one you saw.

And “intersecting” is a geometric fact, not a visual one. An element can be intersecting the viewport while sitting behind a modal, underneath a cookie banner, at opacity: 0, or with visibility: hidden on an ancestor. There’s a second version of the API that tries to answer “was it actually visible to a human,” built for exactly the ad-fraud problem, and it’s in one browser and I’ve never used it.

Check your habits

Every scroll handler I’ve replaced with this has got shorter and stopped showing up in a profile, and I’ve now removed the last one from a project I’ve had since 2016.

The general lesson, if there is one, is the same as the lazy-loading footnote above. For years we did an expensive thing because the platform had no way to express what we actually meant. Then it got one, and the code shrank, and a whole category of “how do I make my scroll handler faster” advice quietly became irrelevant. There’s usually one of those going on. Worth checking occasionally which of your habits is still solving a problem.

Read similar posts
8 min

Five pages and a contact form

Most of the web is brochures, and a brochure doesn't need a client-side runtime, a router, or a build pipeline that stops working the moment you look away from it.

8 min

Service workers on a brochure site

Five pages, a contact form, updated roughly twice a year, and I spent an evening giving it an offline experience. I've been trying since to work out honestly whether that was worth doing or just fun.