A scroll listener on window can fire hundreds of times during one swipe. Run getBoundingClientRect() on every card in a feed on each of those events and fifty images are enough to make the page stutter. The Intersection Observer API takes that check off the scroll path: you register which elements to watch, and the browser tells you when their visibility actually changes.
How IntersectionObserver works
An observer watches one or more target elements against a root (the viewport, by default) and calls a callback whenever a target crosses a visibility threshold.
| |
Each entry carries isIntersecting (a boolean), intersectionRatio (how much of the target sits inside the root, from 0 to 1), and the target’s own boundingClientRect. The callback is asynchronous. The browser computes intersections once per frame as part of its own rendering work, so your code never forces a layout recalculation while the user scrolls.
threshold also accepts an array. [0, 0.5, 1] fires the callback separately at 0%, 50%, and 100% visible, which is how you’d send a “marked as read once half-scrolled” event without creating three observers.
How to lazy-load images with IntersectionObserver
The classic use case: don’t download an image until it’s about to enter the viewport.
| |
| |
rootMargin is what makes this feel instant. A positive value grows the root’s effective box, so the callback fires while the image is still 200px below the fold instead of on its first visible pixel. Keep the explicit width and height on the tag, too. They reserve the space so the layout doesn’t jump when the real image arrives, a separate cause of layout shift that most lazy-loading demos ignore.
Before you write any of this, check whether <img loading="lazy"> is enough. Every current browser supports it natively, no JavaScript involved. The observer version earns its place when you need something the attribute can’t do: a custom placeholder swap, lazy video or iframe embeds, or background images set from CSS.
How to build infinite scroll with a sentinel element
Infinite scroll doesn’t watch the content you’ve already loaded. It watches an empty element below the list and loads more when that sentinel becomes visible.
| |
| |
The unobserve/observe pair is there for slow networks. Without it, a user who scrolls fast enough can trigger the callback again before the first request resolves, and two pages of results land out of order.
loadNextPage is an ordinary fetch call, so it needs the same response.ok check as any other request. The guide to handling Fetch API errors covers what to do when that request 404s or the network drops mid-scroll. On failure, leave the sentinel unobserved and show a retry button. Re-observing straight away puts you in a loop of failing requests.
The older way to do this was a throttled or debounced scroll handler calling getBoundingClientRect() on the sentinel. As the comparison of debounce and throttle shows, both techniques still poll, only less often. An observer doesn’t poll at all. The browser owns the check and your code reacts to the result.
When does the IntersectionObserver callback run?
Not on the microtask queue. If you’ve read about how async/await interacts with the event loop, don’t assume the callback behaves like a promise continuation. The browser schedules it from the rendering pipeline, around paint. That’s why it’s cheap enough to use on a scrolling page, and also why it’s the wrong tool for logic that has to run before the next repaint.
Common IntersectionObserver pitfalls
It tests a bounding box, not pixels. An element reported as 100% intersecting can still be completely hidden behind an opaque modal or a sticky header painted on top of it. The API answers “is this box inside that box”, not “can the user see this”.
One observer per element adds up. Calling new IntersectionObserver() for each of two hundred cards creates two hundred separate observers. Create one per behavior (one for all lazy images, one for the sentinel) and call .observe() on every target that needs it. The callback already receives entries for whichever targets changed.
jsdom doesn’t implement it. In a Jest or Vitest suite running on jsdom, IntersectionObserver is undefined unless you mock it or install a polyfill. Mock the constructor and call the callback yourself with fake entries to test the lazy-load logic in isolation. Don’t try to simulate real scrolling in a unit test.
Observing before the element exists. In a framework that renders asynchronously, calling observer.observe(el) on a ref that’s still null fails silently: no error, no warning, the element is never watched. Observe inside the hook that runs after the DOM node is attached (useEffect in React, mounted in Vue).
When to use IntersectionObserver instead of loading=“lazy”
For plain <img> tags, add loading="lazy" and write no JavaScript. Use IntersectionObserver for what an attribute can’t express: pagination triggers, autoplay-on-visible video, “seen” analytics events, placeholder-to-image swaps. A good first move is to find the scroll listener in your codebase that calls getBoundingClientRect() and replace it with a single shared observer.