Senior 3 min · March 17, 2026

IntersectionObserver — Lost Sentinel Breaks Infinite Scroll

When a sentinel is removed from the DOM, IntersectionObserver stops firing - breaking infinite scroll.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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.

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.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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" />
Output
Images load as they approach the viewport
Production Insight
If data-src is missing, the image will never load.
Always unobserve after load to avoid unnecessary checks.
rootMargin: '200px' gives the connection time to fetch — adjust based on your slowest network.
Key Takeaway
Lazy loading cuts initial page weight by 50%+.
Always unobserve after the element is done.
Use rootMargin to start loading before the user sees 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.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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);
});
Output
Callback fires when element crosses each threshold
Production Insight
Too many thresholds degrade performance – each is a separate check.
rootMargin with negative values can cause element to never intersect if its size is smaller than the negative margin.
In iOS Safari, rootMargin only supports px values, not % or em.
Key Takeaway
threshold array for progress, single value for simple entry/exit.
rootMargin with positive px = early fire, negative = delayed.
Test on slow devices – excessive thresholds cause jank.

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.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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);
}
Output
Loads more content before user reaches the bottom
Production Insight
If the API returns empty array, unobserve the sentinel to prevent endless checks.
If the sentinel is removed from DOM (e.g., by a framework re-render), observer stops forever.
Loading guard is critical – without it, multiple pages load simultaneously.
Key Takeaway
Sentinel must stay in DOM – do not delete it on last page, just unobserve.
rootMargin 300px gives network time.
Always guard against concurrent fetches with isLoading.

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.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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
}, []);
Output
Single observer, disconnects on cleanup
Production Insight
In React or Vue, observers created in useEffect or onMounted must be disconnected in the cleanup function.
Observing thousands of elements can slow down scrolling on low-end devices – consider using IntersectionObserver only for viewport-adjacent elements and virtualize the rest.
The browser will not fire the callback for elements that are scrolled out and back in quickly if they are reobserved after disconnect – reassess thresholds for rapid scrolls.
Key Takeaway
One observer many targets – avoid creating observers per element.
Memory leaks are real – always unobserve or disconnect.
Don't observe invisible elements – remove or unobserve when not needed.

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.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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"
        />
    );
}
Output
Custom hook encapsulates observer logic
Production Insight
The observer must be disconnected in cleanup to avoid memory leaks when the component unmounts.
If the ref changes (element recreated), the useEffect dependency array must include ref.current.
In StrictMode (React), side effects run twice – ensure observer is only created once per mount.
Key Takeaway
Wrap IntersectionObserver in custom hooks or directives.
Always disconnect in cleanup.
Test with framework dev tools to confirm observer lifecycle.
● Production incidentPOST-MORTEMseverity: high

Infinite Scroll Stops Loading After Reaching a Hidden Sentinel

Symptom
Products load initially, then after a few pages, the sentinel is ignored and no more items are fetched — even when scrolling continues.
Assumption
The observer callback checks isLoading flag and continues to work indefinitely as long as the sentinel exists.
Root cause
The sentinel element was accidentally removed from the DOM by a re-render (React framework removed it when reaching the last page). The observer lost its target and stopped firing, but the removal also stopped any further page loads.
Fix
Ensure sentinel is permanently in the DOM and only 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.
Key lesson
  • 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.
Production debug guideCommon reasons your callback never runs or fires too late4 entries
Symptom · 01
Callback never fires for any target
Fix
Check if the browser supports IntersectionObserver (feature detect: 'IntersectionObserver' in window). Polyfill may be needed for older browsers.
Symptom · 02
Callback fires but isIntersecting is always false
Fix
Ensure the target element has dimensions (height > 0). A zero-height element is considered not intersecting. Use rootMargin to include it.
Symptom · 03
Callback fires multiple times unnecessarily
Fix
Check threshold values – single number (e.g., 0.5) fires once per crossing direction. Array thresholds fire at each crossing. Use threshold: [0] for only entry/exit.
Symptom · 04
Performance degrades as more targets are observed
Fix
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.
★ Quick Debug Cheat Sheet: IntersectionObserverOne-liner commands and checks for common IntersectionObserver issues
Observer never fires
Immediate action
Check browser support: 'IntersectionObserver' in window
Commands
console.log('IntersectionObserver supported?', 'IntersectionObserver' in window)
Sanity check: observe a <div> with explicit height and width in the viewport, log entry.isIntersecting
Fix now
Add polyfill: npm install intersection-observer
Fires too early or too late+
Immediate action
Inspect rootMargin and threshold
Commands
console.log(observer.rootMargin, observer.thresholds)
Open DevTools -> Elements -> scroll into view of target, check its bounding rect
Fix now
Set rootMargin: '100px' to trigger earlier; lower threshold to 0.1
Lazy images never load+
Immediate action
Check if data-src attribute is present and img has src placeholder
Commands
document.querySelectorAll('img[data-src]').forEach(img => console.log(img.dataset.src))
Verify observer is created and observe() called on each image
Fix now
Move observer.observe() inside DOMContentLoaded or after dynamic images are added
Memory grows over time+
Immediate action
Check for forgotten unobserve calls
Commands
console.log('Active observations:', observer.takeRecords())
profile memory using Chrome DevTools Memory tab, looking for detached IntersectionObserver targets
Fix now
Call observer.unobserve(target) after each element's task completes, or call observer.disconnect() when component unmounts
IntersectionObserver vs Scroll Event Listeners
AspectIntersectionObserverScroll Event Listeners
PerformanceRuns off main thread; only fires when visibility changesFires on every scroll frame; calls getBoundingClientRect() synchronously
Ease of useSimple setup with callback and optionsRequires manual scroll handler, throttling/debouncing, and geometry calculations
FlexibilityLimited to visibility detection; no other scroll info (position, velocity)Full access to scroll position, delta, and velocity
Browser supportSupported in all modern browsers; polyfill available for IE11Supported everywhere (but performance varies)
Memory managementMust unobserve/disconnect to avoid leaksMust removeEventListener to avoid leaks
Common usesLazy loading, infinite scroll, scroll-triggered animationsParallax, sticky headers, custom scroll-driven animations

Key takeaways

1
IntersectionObserver is the right tool for lazy loading, scroll animations, and infinite scroll.
2
threshold
0 fires when any part of the element is visible. threshold: 1 fires when the entire element is visible.
3
rootMargin extends the effective viewport
positive values fire earlier, negative values later.
4
Always call observer.unobserve(element) after the element is done being observed to avoid memory leaks.
5
The callback receives an array of entries
multiple elements may change visibility state at the same time.
6
Create one observer per root/options combination, then call observe() on many targets.
7
In frameworks, disconnect the observer in the component's cleanup to prevent leaks.

Common mistakes to avoid

5 patterns
×

Forgetting to unobserve after element is processed

Symptom
Element continues to be observed even after its intended action (e.g., image loaded). The callback fires on every scroll direction change, leading to repeated work and possible performance degradation.
Fix
Call observer.unobserve(entry.target) immediately after handling the intersection. For lazy images, do it after setting src.
×

Using threshold [0, 1] without understanding direction

Symptom
Callback fires twice: once when any part enters (isIntersecting becomes true) and again when fully visible (intersectionRatio reaches 1). If you toggle a class based on isIntersecting, the element gets class added on entry and removed on full entry? Actually isIntersecting flips only at the boundary of zero intersectionRatio. With threshold [0, 1], isIntersecting is true from first pixel to last pixel. The callback fires at 0 (entering) and at 1 (fully visible) – both with isIntersecting true. This causes confusion if you expect only one fire.
Fix
If you need only one entry fire, use threshold: [0] and ignore direction. If you need both entry and exit, use threshold: [0] – isIntersecting changes from false to true (entry) and true to false (exit). The array fires at each threshold crossing, but isIntersecting will be true for all thresholds > 0.
×

Not accounting for rootMargin units

Symptom
rootMargin set as '200' (without 'px') or using percentages – does not work; defaults to 0px. Or using negative values that make the element never intersect because the target is smaller than the negative margin.
Fix
Always specify px (e.g., '200px') or use percentage (e.g., '10%') but note browser compatibility. For negative rootMargin, ensure element size is larger than the absolute margin to ever be considered intersecting.
×

Creating a new observer for each element in a loop

Symptom
Thousands of observers created for a list of images, causing memory bloat and poor performance. Each observer runs its own intersection calculations even if they track the same root.
Fix
Create one observer and call 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

Symptom
Observer created inside an iframe with default root (viewport) checks against the iframe's own viewport, not the parent page viewport. To track visibility on the parent page, pass root: document.parentWindow? Actually root must be an Element of the same document. Cross-origin iframes cannot share observers.
Fix
For same-origin iframes, pass the parent document element as root (requires permissions). For cross-origin, you cannot use IntersectionObserver across documents – use postMessage instead.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How would you implement lazy loading of images without IntersectionObser...
Q02JUNIOR
What is the threshold option in IntersectionObserver?
Q03SENIOR
How would you implement infinite scroll using IntersectionObserver?
Q04SENIOR
Explain how IntersectionObserver handles memory leaks.
Q01 of 04SENIOR

How would you implement lazy loading of images without IntersectionObserver?

ANSWER
Before IntersectionObserver, you'd use a scroll event listener with throttling (requestAnimationFrame) and call getBoundingClientRect() on each image to check if it's within the viewport. You'd maintain a list of unloaded images, and on each scroll frame, iterate through them and swap src when visible. This is expensive because it runs on every scroll frame and triggers layout. IntersectionObserver offloads this to the browser and is far more efficient.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between IntersectionObserver and scroll event listeners?
02
What does rootMargin: '200px' mean?
03
Can I use IntersectionObserver for horizontal scrolling?
04
Does IntersectionObserver work for the same element multiple times?
05
How do I handle an element that is already visible on page load?
🔥

That's DOM. Mark it forged?

3 min read · try the examples if you haven't

Previous
MutationObserver API
7 / 9 · DOM
Next
Web APIs Overview