What this usually means
The IntersectionObserver API is deterministic: if the callback isn't firing, one of its configuration parameters — root, rootMargin, or threshold — doesn't match reality. Most often, the root element doesn't create a scroll container (it's not overflow:scroll/auto, or it has zero height), or the rootMargin is too narrow (e.g., '-100px' when you meant '100px'), or the threshold array has values that the element's intersection ratio never reaches (e.g., requiring a 50% visible ratio when the element is tiny or partially hidden by another stacking context). Additionally, if the target element `display: none` or has zero dimensions at observation time, the ratio stays 0 and the callback never fires for that threshold.
The first ten minutes — establish facts before touching code.
- 1Open DevTools Console and run: `new IntersectionObserver(() => console.log('fire'), {}).observe(document.querySelector('your-selector'))` — if that fires, the issue is in your config.
- 2Check the `root` element: ensure it has a CSS `overflow` other than `visible` and a defined height (or is the viewport). Log `rootElement.scrollHeight` and `rootElement.clientHeight`.
- 3Log the observer options before creating it: `console.log({ root, rootMargin, threshold })`. Common mistake: `rootMargin` string with wrong sign or units (e.g., '0' instead of '0px').
- 4Inspect the target element's bounding box and visibility: `console.log(target.getBoundingClientRect(), window.getComputedStyle(target).display)`. If `display: none` or `width/height === 0`, the callback never fires.
- 5Add a `console.trace()` inside the callback to see who called `observe()` — helps find race conditions if the element is removed and re-added.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe `options` object passed to `IntersectionObserver` constructor — specifically `root`, `rootMargin`, and `threshold`
- searchCSS of the `root` element: `overflow`, `height`, `position` (z-index doesn't matter, but stacking context can clip)
- searchThe target element's computed style: `display`, `visibility`, `opacity`, `width`, `height`, `transform`
- searchAny parent with `overflow: hidden` or `clip-path` that may clip the target away from the root
- searchBrowser DevTools Performance panel — record a scroll and check if `IntersectionObserver` events appear
- searchNetwork tab if the element is loaded dynamically — observer might be attached before element is in DOM
Practical causes, not theory. These are the things you will actually find.
- warning`root` element has no overflow or zero height — observer falls back to viewport silently if `root` is not a valid scroll container
- warning`rootMargin` uses wrong syntax: missing 'px' or negative sign where positive needed (e.g., `-50px` instead of `50px`)
- warning`threshold` array contains a value the element never reaches (e.g., `[0.5]` but element is too small to be 50% visible)
- warningElement is `display: none` or has zero dimensions when `observe()` is called — intersection ratio stays 0
- warningObserver is created but never `observe()` called due to conditional logic (e.g., inside an `if` that evaluates false)
- warningElement is removed and re-added to DOM without calling `unobserve()` and `observe()` again — old observer still watches a detached node
Concrete fix directions. Pick the one that matches your root cause.
- buildEnsure `root` element is a valid scroll container: set `overflow: auto` or `scroll` and give it a fixed or max-height.
- buildAlways specify units on `rootMargin`: e.g., `'0px 0px -50px 0px'`. Use a positive margin to trigger earlier, negative to trigger later.
- buildUse `threshold: [0, 0.1, 0.2, ..., 1]` or at least `[0, 1]` to cover entry and exit. For single-fire, use `threshold: 0`.
- buildBefore calling `observe()`, confirm the target is in the DOM and visible: `if (target && target.offsetParent !== null)`.
- buildIf the element is dynamically inserted, use a MutationObserver or wait for `requestAnimationFrame` before attaching the IntersectionObserver.
- buildDisconnect and re-observe when the DOM updates: call `observer.disconnect()` then `observer.observe(newTarget)`.
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fix, open DevTools and run `document.querySelector('your-selector').scrollIntoView({ behavior: 'instant' })` — callback should fire.
- verifiedAdd a console log with the `entry.intersectionRatio` inside the callback: `console.log(entry.intersectionRatio)` — confirm it meets threshold.
- verifiedTest in all target browsers (Chrome, Firefox, Safari) — Safari is stricter about root being a scroll container.
- verifiedUse `performance.mark()` and `performance.measure()` to time callback firing relative to scroll events.
- verifiedSet up a minimal reproduction in a CodePen or isolated HTML file — if it works there, the issue is in your app's specific CSS/JS.
Things that make this bug worse or harder to find.
- warningAssuming `root: null` (viewport) works the same as specifying a custom root — it does, but only if no root is passed.
- warningUsing `rootMargin` with a single value like `'10px'` — that sets all four margins to 10px, which may be unintended.
- warningCreating a new `IntersectionObserver` inside a scroll event listener — you only need one observer per configuration.
- warningCalling `observe()` on an element that is not yet attached to the DOM — the observer will fire once when attached, but if attached off-screen, it won't fire until scrolled.
- warningForgetting to call `unobserve()` on old elements in a dynamic list — leads to memory leaks and stale callbacks.
- warningExpecting the callback to fire synchronously — it fires asynchronously during the next frame after visibility changes.
The Infinite Scroll That Never Loaded Page 2
Timeline
- 10:00Deploy new infinite scroll with IntersectionObserver for dashboard list.
- 10:15User reports: 'Scrolling to bottom does nothing. No new items load.'
- 10:20Check DevTools: no errors, IntersectionObserver callback never fires.
- 10:25Log observer options: `root: document.querySelector('.list-container')`, `rootMargin: '0px 0px -50px 0px'`, `threshold: 0`.
- 10:30Inspect `.list-container`: `height: 100%` but parent has no explicit height. `overflow: hidden` (should be auto).
- 10:35Fix: add `overflow-y: auto` and `max-height: 80vh` to container. Callback fires immediately.
- 10:40Deploy fix. User confirms infinite scroll works.
I had just pushed a new infinite scroll feature for our analytics dashboard. It used IntersectionObserver to detect when the user scrolled near the bottom of a list and then fetch more data. Five minutes later, a support ticket came in: 'Page 2 never loads.' I opened the dashboard and scrolled down — nothing. No errors, no network requests. The observer callback simply never fired.
I started by logging the observer configuration: root was the list container, rootMargin was '0px 0px -50px 0px', threshold 0. That looked correct. Then I inspected the container element in DevTools: it had `height: 100%` and `overflow: hidden`. The parent element had no explicit height, so the container's actual height was 0. The root had no scrollable overflow. The IntersectionObserver silently fell back to the viewport as root, but my rootMargin assumed a different root. The element was visible in the viewport, but the margin offset didn't align with the viewport, so the callback never triggered.
The fix was simple: I added `overflow-y: auto` and `max-height: 80vh` to the container. This made it a proper scroll container. The callback started firing on scroll. I also added a sanity check in the code: if the container's scrollHeight equals its clientHeight, log a warning that there's nothing to scroll. The lesson: never assume a container is scrollable — verify its CSS overflow and height. And always test with real data that fills the container.
Root cause
Root element (list container) had `overflow: hidden` and zero effective height, making it a non-scrollable container. IntersectionObserver fell back to viewport, but rootMargin didn't match viewport behavior.
The fix
Changed container CSS to `overflow-y: auto` and `max-height: 80vh`. Added a guard to warn if container is not scrollable.
The lesson
Always verify that the root element is a valid scroll container (overflow: auto/scroll, defined height). Use the viewport (root: null) for simpler cases unless you need a custom scroll container.
The IntersectionObserver API requires the `root` element to be a scroll container. This means it must have a CSS `overflow` property set to `auto`, `scroll`, or `hidden` (yes, `hidden` works too, but it clips overflow). If the root is not a scroll container, the observer silently uses the viewport as root. This is a common gotcha: you pass a custom root, but if it's not scrollable, the observer ignores it.
To check if an element is a scroll container, inspect its `overflow` computed style. In Chrome DevTools, select the element and look at the Computed tab. Alternatively, run `window.getComputedStyle(element).overflow` in the console. Also verify the element has a defined height — otherwise `clientHeight` is 0 and scrolling doesn't happen. Common culprits: `height: 100%` on an element whose parent has no explicit height, or `min-height` only.
The `rootMargin` string is parsed as a CSS margin value. A common mistake is omitting units: `'0'` is invalid and the entire observer may fail to create (though in practice, browsers may treat it as `0px`). Another trap: using negative margins when you meant positive. `rootMargin: '-50px'` shrinks the root's bounding box, causing the callback to fire later (when the element is 50px inside the root). If you want to trigger earlier (before the element enters), use positive margins.
Always write `rootMargin` with explicit units, e.g., `'0px 0px -50px 0px'`. The order is top, right, bottom, left. For a typical 'load more when near bottom' scenario, you want a negative bottom margin: `'0px 0px -100px 0px'` fires when the target is 100px below the bottom of the root (i.e., when it's about to enter). If you use positive bottom margin, it fires after the target has entered.
The `threshold` determines at what intersection ratio the callback fires. If you set `threshold: 0.5`, the callback fires when exactly 50% of the target is visible. However, if the target is very small or partially hidden by other elements (e.g., a fixed header), the intersection ratio may never reach exactly 0.5. Floating point precision can also cause misses: a ratio of 0.499999 might not trigger threshold 0.5.
To avoid missed firings, use an array of thresholds: `[0, 0.1, 0.2, ..., 1]` or at least `[0, 1]` to detect entry and exit. For a 'fire once when visible' pattern, use `threshold: 0`. The callback fires as soon as any pixel is visible. To fire once when fully visible, use `threshold: 1` but note it fires again when leaving.
When the callback isn't firing, use the Performance panel to confirm that IntersectionObserver entries are generated. Record a scroll session and look for 'IntersectionObserver' under Timings. If no entries appear, the observer is not being triggered at all. Also, listen to `scroll` events on the root to confirm scrolling is happening: `root.addEventListener('scroll', () => console.log('scroll'))`.
Another technique: override the native IntersectionObserver to log options and calls: `const OrigObserver = window.IntersectionObserver; window.IntersectionObserver = function(cb, opts) { console.log('created with', opts); return new OrigObserver(cb, opts); }`. This helps catch if the observer is created with wrong options or never created at all.
Frequently asked questions
My IntersectionObserver callback fires once but never again when I scroll back. Why?
This happens if the observer is disconnected or the target element is removed and re-added without re-observing. Also, if you only set `threshold: 0`, the callback fires once when the element enters the viewport, but not when it leaves. To detect both enter and exit, use `threshold: [0, 1]` or check `entry.isIntersecting` inside the callback.
Does `root: null` always use the viewport? What about iframes?
Yes, `root: null` uses the browser viewport as the root. In iframes, the viewport is the iframe's own viewport, not the parent document. If you need to observe relative to a parent element inside the iframe, you must pass that element explicitly as root.
Can I use IntersectionObserver with an SVG element as target?
Yes, but only if the SVG element has a CSS layout box (e.g., `rect`, `circle`, `text`). The SVG root `<svg>` itself is treated as a replaced element and works as a target, but as a root it may not behave as a scroll container unless it has overflow styles applied.
Why does my observer work in Chrome but not Safari?
Safari is stricter about the root element being a scroll container. Chrome may fall back to the viewport more leniently. Also, Safari has had bugs with `rootMargin` units — always include 'px'. Make sure your root has explicit `overflow` and `height`.
Is there a performance penalty for using many thresholds?
Each threshold value adds a callback invocation. For most use cases, 2–3 thresholds (e.g., `[0, 0.5, 1]`) are fine. Avoid arrays of 100 values. The observer itself is efficient; the bottleneck is usually the callback logic, not the threshold evaluation.