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.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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 asynchronously observes changes in the intersection of a target element with an ancestor element or the viewport. It solves the performance nightmare of scroll-based event listeners — the classic onscroll handler that fires hundreds of times per second, thrashing the main thread with layout recalculations and DOM queries.
Instead of polling scroll position, you register a callback that fires only when an element's visibility crosses a configurable threshold. This is the foundation for lazy-loading images, infinite scroll, and analytics tracking without degrading frame rates.
The API is supported in all modern browsers since 2019, including Chrome 51+, Firefox 55+, Safari 12.1+, and Edge 15+. For legacy browser support, you'd fall back to scroll event listeners or use a polyfill like intersection-observer (npm package, ~1.5KB gzipped).
Don't use it for animations tied to scroll progress — that's still requestAnimationFrame territory. And if you need to track every pixel of scroll position (e.g., parallax), IntersectionObserver won't help; it only tells you when thresholds are crossed, not continuous position data.
The API is also not a replacement for ResizeObserver when you need element dimension changes.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
IntersectionObserver is the browser API that replaces scroll-event listeners for visibility detection — it's what powers lazy loading, infinite scroll, and scroll-triggered animations without jank. But it's not a magic bullet: if your sentinel element gets removed from the DOM, the observer stops firing silently, breaking your infinite scroll. This article walks through the API's mechanics, common pitfalls like the lost sentinel, and production patterns for frameworks and performance.
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.
// Lazy load images as they scroll into view const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; // load the real image img.classList.add('loaded'); observer.unobserve(img); // stop watching once loaded } }); }, { rootMargin: '200px', // start loading 200px before entering viewport threshold: 0 // fire as soon as any part is visible }); // Observe all images with data-src attribute document.querySelectorAll('img[data-src]').forEach(img => { imageObserver.observe(img); }); // HTML: // <img data-src="/images/photo.jpg" src="/images/placeholder.jpg" />
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.
const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { console.log(`${entry.target.id}: ${entry.intersectionRatio.toFixed(2)} visible`); console.log('isIntersecting:', entry.isIntersecting); }); }, { root: null, // null = viewport (default) rootMargin: '0px', // expand/contract the root's bounding box threshold: [0, 0.25, 0.5, 0.75, 1.0] // fire callback at each threshold // threshold: 0.5 fires when element is 50% visible }); // Scroll animation — add class when element enters viewport const animationObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { entry.target.classList.toggle('visible', entry.isIntersecting); }); }, { threshold: 0.1 }); // fire when 10% visible document.querySelectorAll('.animate-on-scroll').forEach(el => { animationObserver.observe(el); });
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.
// Observe a sentinel element at the bottom of the list const sentinel = document.querySelector('#load-more-sentinel'); let page = 1; let isLoading = false; const scrollObserver = new IntersectionObserver(async (entries) => { const [entry] = entries; if (!entry.isIntersecting || isLoading) return; isLoading = true; page++; try { const response = await fetch(`/api/items?page=${page}`); const items = await response.json(); if (items.length === 0) { scrollObserver.unobserve(sentinel); // no more pages sentinel.remove(); return; } items.forEach(item => renderItem(item)); } finally { isLoading = false; } }, { rootMargin: '300px' }); // load before hitting the bottom scrollObserver.observe(sentinel); function renderItem(item) { const el = document.createElement('div'); el.textContent = item.title; document.querySelector('#list').appendChild(el); }
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.
// Good: one observer for all targets const observer = new IntersectionObserver((entries) => { // handle entries }, { threshold: 0 }); document.querySelectorAll('.observe-me').forEach(el => observer.observe(el)); // Cleanup on navigation/component unmount function cleanup() { observer.disconnect(); } // In React: useEffect(() => { return () => observer.disconnect(); // cleanup on unmount }, []);
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.
// React custom hook for intersection observer function useIntersectionObserver(ref, options = {}) { const [isIntersecting, setIsIntersecting] = useState(false); useEffect(() => { if (!ref.current) return; const observer = new IntersectionObserver(([entry]) => { setIsIntersecting(entry.isIntersecting); }, options); observer.observe(ref.current); return () => observer.disconnect(); }, [ref, options]); return isIntersecting; } // Usage: function LazyImage({ src, placeholder }) { const imgRef = useRef(); const isVisible = useIntersectionObserver(imgRef, { rootMargin: '100px' }); return ( <img ref={imgRef} src={isVisible ? src : placeholder} alt="" loading="lazy" /> ); }
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.
// io.thecodeforge const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { // intersectionRatio: visible area / target bounding box area console.log(`Ratio: ${entry.intersectionRatio.toFixed(3)}`); console.log(`Bounding box:`, entry.boundingClientRect); console.log(`Intersection rect:`, entry.intersectionRect); console.log(`Root bounds:`, entry.rootBounds); // isIntersecting is true when intersectionRatio > 0 if (entry.intersectionRatio > 0.5) { // Trigger action when more than half visible } } }, { threshold: [0, 0.25, 0.5, 0.75, 1] } ); observer.observe(document.getElementById('critical-ad-unit'));
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.
// io.thecodeforge const animateObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { // Only toggle class — no inline styles! entry.target.classList.add('animate-in'); // Stop observing once animation triggers animateObserver.unobserve(entry.target); } } }, { threshold: 0.2, rootMargin: '0px 0px -50px 0px' } ); // Observe all cards with data-animate attribute document.querySelectorAll('[data-animate]').forEach((el) => { animateObserver.observe(el); }); // In React cleanup: // useEffect(() => { return () => animateObserver.disconnect(); }, []);
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.isIntersectingconsole.log(observer.rootMargin, observer.thresholds)Open DevTools -> Elements -> scroll into view of target, check its bounding rectdocument.querySelectorAll('img[data-src]').forEach(img => console.log(img.dataset.src))Verify observer is created and observe() called on each imageobserver.observe() inside DOMContentLoaded or after dynamic images are addedconsole.log('Active observations:', observer.takeRecords())profile memory using Chrome DevTools Memory tab, looking for detached IntersectionObserver targetsobserver.disconnect() when component unmounts| Aspect | IntersectionObserver | Scroll Event Listeners |
|---|---|---|
| Performance | Runs off main thread; only fires when visibility changes | Fires on every scroll frame; calls getBoundingClientRect() synchronously |
| Ease of use | Simple setup with callback and options | Requires manual scroll handler, throttling/debouncing, and geometry calculations |
| Flexibility | Limited to visibility detection; no other scroll info (position, velocity) | Full access to scroll position, delta, and velocity |
| Browser support | Supported in all modern browsers; polyfill available for IE11 | Supported everywhere (but performance varies) |
| Memory management | Must unobserve/disconnect to avoid leaks | Must removeEventListener to avoid leaks |
| Common uses | Lazy loading, infinite scroll, scroll-triggered animations | Parallax, sticky headers, custom scroll-driven animations |
| File | Command / Code | Purpose |
|---|---|---|
| intersection-calc.js | const observer = new IntersectionObserver( | How Intersection is Actually Calculated |
| scroll-animate.js | const animateObserver = new IntersectionObserver( | Building a Scroll-Triggered Animation That Won't Jank |
Key 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?
What is the threshold option in IntersectionObserver?
How would you implement infinite scroll using IntersectionObserver?
Explain how IntersectionObserver handles memory leaks.
observer.disconnect(), the element is retained by the observer, preventing garbage collection even if the element is removed from the DOM. In SPAs, components that mount and unmount can create many observers that are never cleaned up, causing memory leaks. Always clean up by calling observer.disconnect() or unobserve() in the component's cleanup lifecycle (useEffect return, componentWillUnmount).Frequently Asked Questions
Scroll events fire on every scroll frame and require synchronous getBoundingClientRect() calls which trigger layout. IntersectionObserver runs asynchronously off the main thread, only calls back when visibility actually changes, and is significantly more performant. Use IntersectionObserver for any visibility-based logic.
It expands the root element's bounding box by 200px in all directions for the purpose of intersection calculations. A target 200px below the viewport will be considered 'intersecting' with a rootMargin of '200px 0px'. This is how you start loading content before the user scrolls to it.
Yes. The observer works in both horizontal and vertical scrolling. The rootMargin applies in all directions unless you specify individual values (top, right, bottom, left). For a horizontal only scroll, you can set rootMargin: '0px 200px' to expand horizontally.
You can observe the same element only once per observer. If you need to observe it with different thresholds or rootMargin, create a separate observer for that configuration. The element can be observed by multiple different observers simultaneously.
The observer will fire the callback immediately (asynchronously) if the element is already intersecting when observed. For lazy loading, this is fine – the image loads right away. If you want to avoid an initial fire, check the entry.intersectionRatio or use a condition to ignore the first intersection if needed.
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?
4 min read · try the examples if you haven't