IntersectionObserver — Lost Sentinel Breaks Infinite Scroll
When a sentinel is removed from the DOM, IntersectionObserver stops firing - breaking infinite scroll.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- IntersectionObserver asynchronously notifies when a target element enters/exits the viewport or a container
- Create an observer with a callback and options, then call observer.observe(element)
- Callback receives an array of IntersectionObserverEntry objects with isIntersecting, intersectionRatio, target
- rootMargin extends the viewport – positive values fire earlier, negative later
- threshold array triggers callbacks at multiple visibility levels (e.g., [0, 0.25, 0.5, 0.75, 1])
- Always call unobserve() when done to avoid memory leaks and unnecessary computations
IntersectionObserver is a browser API that tells you when an element scrolls into view (or out of view). Instead of constantly checking scroll position yourself, the browser does the hard work and calls your code only when visibility changes. Great for lazy loading images, infinite scrolling, and triggering animations when elements appear on screen.
IntersectionObserver — The Browser's Efficient Scroll Listener
IntersectionObserver is a browser API that asynchronously observes changes in the intersection of a target element with an ancestor element or the viewport. Instead of polling scroll positions or attaching scroll events, you register a callback that fires when the observed element's visibility crosses a configurable threshold. The core mechanic is declarative: you tell the browser what to watch and at what ratio, and it batches intersection updates in a single microtask, avoiding layout thrashing.
Practically, the API exposes an array of IntersectionObserverEntry objects, each containing isIntersecting, intersectionRatio, boundingClientRect, and intersectionRect. The rootMargin property lets you trigger callbacks before the element actually enters the viewport — useful for preloading. The threshold can be a single number or an array (e.g., [0, 0.25, 0.5, 0.75, 1]) to fire at multiple visibility levels. The callback runs asynchronously, decoupled from the main thread's scroll handling, which is why it avoids jank.
Use IntersectionObserver for lazy-loading images, infinite scroll, animation triggers, and ad visibility tracking. In production, it replaces scroll-event-based infinite scroll that causes dropped frames and missed detections on fast scrolls. The API is supported in all modern browsers and is the standard way to implement performant visibility detection without manual scroll listeners.
Basic Usage — Lazy Loading Images
The most common use case for IntersectionObserver is lazy loading images. Instead of loading all images on page load, you load a placeholder and replace the src when the image scrolls into view. This reduces initial page weight and speeds up perceived performance.
The observer is created with a callback that fires whenever an observed element's visibility changes. Inside the callback, check entry.isIntersecting – when true, swap the real image URL from a data-src attribute, add a class, then unobserve the element to stop watching it.
threshold and rootMargin Options
The threshold option defines at what percentage of the target's visibility the callback fires. A single value like 0.5 fires when exactly half the element is visible (in either direction). An array [0, 0.25, 0.5, 0.75, 1] fires five times per crossing. Use this for animations that need to know overlap progress.
rootMargin expands (positive px) or shrinks (negative px) the root's bounding box before checking intersection. A positive rootMargin makes the observer fire earlier; negative delays the fire. This is useful for pre-loading content or avoiding action until the element is comfortably inside the viewport.
Infinite Scroll
Infinite scroll uses a sentinel element placed after the content list. When the sentinel becomes visible, you fetch the next page of data and append it. IntersectionObserver on the sentinel fires the fetch logic when the user scrolls near the bottom.
Critical details: use a loading guard (isLoading) to avoid duplicate fetches, and unobserve the sentinel when there is no more data (to stop unnecessary calls). The rootMargin should be positive (e.g., 300px) to start loading before the user hits the bottom, giving the network request time to complete.
Performance and Memory Management
Each IntersectionObserver instance uses a small amount of system resources. When observing many elements (hundreds or thousands), you risk main thread overhead from the observer's internal checks. Use a single observer for many elements rather than creating per-element observers. The browser handles the intersection calculations efficiently as long as the number of observed elements stays reasonable (< 1000).
Memory leaks occur when you forget to call unobserve() or disconnect(). Observed elements that are removed from the DOM without being unobserve'd hold references that prevent garbage collection. This is especially problematic in SPAs where components mount and unmount repeatedly.
Using IntersectionObserver with Frameworks (React, Vue, Angular)
In component-based frameworks, you typically create an IntersectionObserver inside a component's lifecycle (useEffect in React, onMounted in Vue, ngAfterViewInit in Angular) and disconnect it when the component unmounts. Use refs to get a reference to the DOM element to observe.
For lazy loading images, you can encapsulate the logic in a custom hook (React) or a directive (Angular). The key is to ensure the observer is created after the element is rendered and cleaned up when the element is removed.
How Intersection is Actually Calculated — Stop Guessing
You're reading the docs, setting thresholds, but do you actually know what the browser computes? That 'isIntersecting' boolean hides a precise geometry calculation you must understand to avoid edge-case bugs.
The Intersection Observer computes the intersection ratio by dividing the area of the visible portion of your target element (within the root) by the target's total bounding box area. This ratio is a float between 0.0 and 1.0. If your root is the viewport (default), the browser clips the target against the viewport rectangle. If you specify a root element, it clips against that element's content box, minus any rootMargin you've set. The calculation uses the CSS border-box of the target, not the content-box — a detail that trips up developers when padding or borders unexpectedly shift the intersection point.
The callback fires only when the computed intersection ratio crosses one of your threshold values, not on every pixel scroll. This is what makes the API performant: the browser batches these geometric checks on its own timeline, off the main thread. But here's the trap: if your target has overflow: hidden ancestors inside the root, the browser still checks intersection against the root's viewport, not those parent clipping boundaries. You must ensure no intermediate overflow clips the target unless you intend that behavior.
isIntersecting for animations. On mobile, the intersection rect can be one pixel tall and still report true. Always check intersectionRatio >= your_min_threshold instead.Building a Scroll-Triggered Animation That Won't Jank
Animations on scroll are the #1 cause of jank in single-page apps. In production last quarter, I saw a team implement an entrance animation using a scroll event listener that called getBoundingClientRect() on every frame. Frame drops from 60fps to 12fps. The fix? Delegate to Intersection Observer.
Here's the pattern: Create one observer with a single threshold at 0.2 (20% visible). When the callback fires and isIntersecting is true, apply a CSS class that triggers a transition or animation. Do NOT touch element.style inside the callback — that forces an immediate style recalc. Instead, toggle a class and let CSS transitions handle the interpolation on the compositor thread.
Why this works: The observer callback runs in a microtask, but the browser batches DOM writes. If you add a class, the repaint happens on the next frame. However, if you animate inline styles (e.g., element.style.transform = ...), you force layout thrashing. The rule is: observer callbacks should only toggle booleans or classes, never manipulate inline properties.
For chained animations (elements entering sequentially), use thresholds as an array [0.1, 0.2, 0.3] so each element triggers independently. But beware: each threshold crossing fires a separate callback batch. Keep thresholds to 3 or fewer to avoid overloading the event loop. And always disconnect observers in your component's cleanup hook — leaking observers on SPAs causes memory bloat as users navigate.
threshold: [0, 0.01, 0.02, ..., 1] for a smooth animation. Each threshold crossing fires a separate callback batch — 100 thresholds = 100 batches per scroll pass. Stick to 1–3 thresholds and use CSS transitions for smoothness.Infinite Scroll Stops Loading After Reaching a Hidden Sentinel
observer.unobserve() removes the observer. Use a rootMargin of 300px so the sentinel can be positioned absolutely below the list, never removed on re-render.- IntersectionObserver stops firing if the target element is removed from the DOM.
- Always keep sentinel elements outside the rendering logic that might detach them.
- Test infinite scroll with partial data and verify observer fires after multiple loads.
Observer.unobserve() targets after they finish (e.g., after image loaded). Do not observe more than ~100 elements per observer; split into multiple observers if needed.console.log('IntersectionObserver supported?', 'IntersectionObserver' in window)Sanity check: observe a <div> with explicit height and width in the viewport, log entry.isIntersectingKey takeaways
observe() on many targets.Common mistakes to avoid
5 patternsForgetting to unobserve after element is processed
Using threshold [0, 1] without understanding direction
Not accounting for rootMargin units
Creating a new observer for each element in a loop
observe() on each target element. The observer's callback receives an array of entries for that single observer. Reuse it.Assuming IntersectionObserver works inside iframes
Interview Questions on This Topic
How would you implement lazy loading of images without IntersectionObserver?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's DOM. Mark it forged?
5 min read · try the examples if you haven't