Senior 5 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 & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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
✦ Definition~90s read
What is IntersectionObserver API?

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.

IntersectionObserver is a browser API that tells you when an element scrolls into view (or out of view).

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.

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.

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.

Not a Replacement for ResizeObserver
IntersectionObserver detects visibility changes, not size changes. If you need to react to element resizing, use ResizeObserver — they are complementary, not interchangeable.
Production Insight
Teams using scroll events for infinite scroll on mobile see 200ms+ input delay and dropped frames because scroll handlers block the main thread.
The symptom: users scroll to the bottom, nothing loads, then 10 items appear suddenly after a 500ms pause — a classic 'janky infinite scroll'.
Rule of thumb: never attach a scroll listener for visibility detection; always use IntersectionObserver with a rootMargin of '200px 0px' to trigger loads before the user reaches the bottom.
Key Takeaway
IntersectionObserver decouples visibility detection from scroll events, eliminating layout thrashing and jank.
Use rootMargin to trigger actions before the element is visible — this is how you preload content without user-perceptible delay.
The callback runs asynchronously in a microtask, so you cannot rely on synchronous state — always check isIntersecting inside the callback.
IntersectionObserver: Efficient Infinite Scroll & Lazy Loading THECODEFORGE.IO IntersectionObserver: Efficient Infinite Scroll & Lazy Loading Flow from observer setup to scroll-triggered actions without jank Create IntersectionObserver Pass callback and options (threshold, rootMargin) Observe Target Elements Call observer.observe(element) on each sentinel Callback Fires on Intersection Entries array with isIntersecting, intersectionRatio Lazy Load or Load More Set src on images or fetch next page data Unobserve After Use Call observer.unobserve(element) to free memory ⚠ Forgetting to unobserve can cause memory leaks and stale callbacks Always unobserve after action, or use {once: true} option THECODEFORGE.IO
thecodeforge.io
IntersectionObserver: Efficient Infinite Scroll & Lazy Loading
Intersection Observer Api

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.

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.

intersection-calc.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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'));
Output
// Console output when element is 60% visible:
// Ratio: 0.600
// Bounding box: {x: 150, y: 800, width: 300, height: 200, top: 800, right: 450, bottom: 1000, left: 150}
// Intersection rect: {x: 150, y: 800, width: 300, height: 120, top: 800, right: 450, bottom: 920, left: 150}
// Root bounds: {x: 0, y: 0, width: 1920, height: 900}
Production Trap:
Never rely solely on isIntersecting for animations. On mobile, the intersection rect can be one pixel tall and still report true. Always check intersectionRatio >= your_min_threshold instead.
Key Takeaway
The intersection ratio is area-based, not pixel-based, and the callback fires only at threshold crossings — never on every scroll tick.

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.

scroll-animate.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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(); }, []);
Output
// CSS needed (separate file):
// .animate-in { animation: fadeInUp 0.6s ease-out forwards; }
// @keyframes fadeInUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
Production Trap:
Never set 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.
Key Takeaway
Use Intersection Observer only to toggle A/B visibility classes; let CSS transitions handle the in-between frames to keep animations jank-free.
● 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?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's DOM. Mark it forged?

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

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