An infinite-scroll list observes a sentinel div at the bottom of the page with IntersectionObserver to fetch the next page. In production it fires the fetch several times for the same page, and once a batch lands it sometimes stops firing entirely. Explain both behaviours and how you would make the trigger reliable.
answer
- crossings, not a standing signal
- the observer knows nothing about your fetch
- a short batch leaves it parked inside
- re-registering queues a fresh baseline
- guard the request, re-arm the sentinel
basics
~20 sIntersectionObserver is edge-triggered: it reports threshold crossings, not a standing state. Repeated crossings while a fetch is still pending cause duplicate loads, and a sentinel that stays inside the root after a short batch never crosses again, so nothing more arrives. Add an in-flight guard, and re-observe the sentinel after each render to force a fresh report.
solid answer
~50 sBoth symptoms come from the same property: entries arrive on **crossings**, not continuously, and the observer has no memory of your fetch. Duplicate loads happen because the sentinel can cross the tripwire again — from the initial observation, from jitter around the edge during scrolling, or from layout shifting while the request is still in flight — and each crossing calls your handler again. The stall is the mirror image: if the appended batch is shorter than the viewport, the sentinel is still inside the (rootMargin-expanded) root afterwards, the ratio never changes, and no further entry is ever delivered. The fix is a state machine you own, not tuning the observer: keep an `isLoading` flag (or `unobserve` the sentinel while the request runs), and after the new rows are in the DOM call `observe()` on the sentinel again — a fresh registration queues an initial observation that re-reports the current state and re-arms the loop.
code
javascript · 18 linesconst sentinel = document.querySelector('#sentinel');
let loading = false;
let nextPage = 1;
const io = new IntersectionObserver(async (entries) => {
const entry = entries[0];
if (!entry.isIntersecting || loading) return;
loading = true;
io.unobserve(sentinel);
try {
await appendPage(nextPage++);
} finally {
loading = false;
io.observe(sentinel);
}
}, { rootMargin: '400px 0px', threshold: 0 });
io.observe(sentinel);go deeper
Know the pattern: an empty sentinel element at the end of the list, observed once, whose intersecting entry triggers loading the next page. Remember to guard against loading twice.
Explain edge triggering: entries arrive on crossings, so a sentinel that stays inside the root delivers nothing more. Describe the in-flight flag and re-observing the sentinel after the new rows render.
Diagnose both symptoms from the same property and defend the fix: state you own, an observer created once, re-registration to force a fresh baseline, and teardown when the last page arrives. Name the geometry traps — display: none, the wrong root, an unreachable threshold.
Own the pattern once for the codebase — a reviewed hook or utility with the guard, re-arm and end-of-feed teardown baked in — rather than letting each feed reinvent it. Duplicate page requests are a billing and backend-load issue, not just a UI glitch.
## One property, two bug reports `IntersectionObserver` is edge-triggered. It hands you an entry when the target's intersection ratio crosses one of your thresholds, in either direction, plus one baseline entry when you first register the target. It never streams the current state, and it holds no knowledge of what your callback did last time. Almost every infinite-scroll bug is a consequence of forgetting one half of that sentence. ## Why the same page loads several times Several independent things can produce more than one intersecting entry before the first fetch resolves: - The **initial observation** fires as soon as you observe the sentinel. If the list starts shorter than the viewport, the sentinel is already on screen and page two is requested immediately — sometimes twice, if the component sets up the observer more than once. - **Jitter at the boundary.** During scrolling, the ratio can cross the tripwire, fall back, and cross again. Each is a legitimate crossing. - **Layout movement while loading.** Skeleton rows, an image that finally sizes itself, or a spinner appearing and disappearing all move the sentinel across the edge. - **Slow networks.** The callback runs again long before `await fetchPage()` settles, and nothing in the platform serialises those calls for you. The observer is behaving correctly in all four cases; the missing piece is that "a request is already running" is application state, and only your code holds it. ```js let loading = false; let nextPage = 1; const io = new IntersectionObserver(async (entries) => { const entry = entries[0]; if (!entry.isIntersecting || loading) return; loading = true; io.unobserve(sentinel); // belt and braces: no crossings while fetching try { await appendPage(nextPage++); } finally { loading = false; io.observe(sentinel); // re-arm, and get a fresh baseline entry } }, { rootMargin: '400px 0px' }); io.observe(sentinel); ``` ## Why it then stops firing This is the half people miss. Suppose the batch that just landed adds three short rows. The sentinel moves down a little but is still inside the root — especially with a generous `rootMargin`. Its ratio did not cross anything, so **no entry is delivered**, and code waiting for "the next callback" waits forever. The list stalls with the sentinel sitting on screen, and only a manual scroll up and back down revives it. There is no "give me the current state" call. `takeRecords()` exists on `IntersectionObserver`, but it returns entries that are already queued and undelivered — it does not compute a fresh observation. The reliable re-arm is to `unobserve` and `observe` the sentinel again: a new registration queues an initial observation, which reports the state as it stands now, so a still-intersecting sentinel immediately asks for the next page. That is exactly what the `finally` block above does, which is why the same three lines fix both symptoms. ## The other reasons a sentinel never fires When debugging one that has never worked at all, check the geometry before the logic: - **`display: none` on the sentinel** — no box, so no intersection, ever. A zero-*height* sentinel is fine (the spec defines a zero-area target as intersecting with ratio 1 when it overlaps), but a removed box is not. - **The wrong root.** If the list scrolls inside a container and `root` was left as the viewport, everything reads as intersecting at once; if `root` is set to something that is not an ancestor of the sentinel, nothing ever intersects. - **A sentinel rendered after the observer runs**, or replaced on every render so the observed node is detached while a new one sits in the DOM. Observe the node you actually have, and re-observe when it changes identity. - **`threshold: 1` on a sentinel wider than the root**, which is unreachable. ## Design choices worth defending A generous `rootMargin` (a few hundred pixels) starts the next page before the user reaches the end, which is the whole reason to use a sentinel rather than a scroll handler. It also makes the stall more likely, because the sentinel spends more time inside the expanded box — another reason the re-observe is not optional. Create the observer **once** and keep it, rather than constructing a new one per render; a new observer per update multiplies the initial observations and is the fastest way back to duplicate fetches. And when the feed reaches its last page, `unobserve` (or `disconnect`) the sentinel so the loop cannot re-arm itself, then remove the sentinel from the DOM. ## The one-sentence version "Entries are crossings, not a state feed, and the observer does not know about my request — so I hold the in-flight state myself, and I re-register the sentinel after each render to get a fresh reading." Everything else is detail hanging off that.
- Does calling takeRecords() give you the sentinel's current state?No. `takeRecords()` returns entries that were already queued but not yet delivered to the callback, and empties that queue. It never computes a new observation, so it cannot tell you the state of a target that has not crossed anything. To force a fresh reading, `unobserve` then `observe` the target — the new registration queues an initial observation.
- Why not just use a bigger rootMargin instead of re-observing?A bigger margin fetches earlier, but it makes the stall *more* likely: the sentinel spends longer inside the expanded box, so it is more likely to sit there without crossing after a short batch lands. The margin tunes when loading starts; only re-arming fixes the missing signal.
- What do you do when the feed reaches its last page?Stop observing — `unobserve(sentinel)` or `disconnect()` — and remove the sentinel from the DOM, so no re-arm can request a page that does not exist. Leaving it registered means every scroll to the bottom keeps evaluating a target whose answer cannot change anything, and any stray crossing re-enters the loader.
saying these in an interview costs you the question
- Expects the callback to keep firing while the sentinel stays on screen
- Relies on the observer to serialise overlapping fetch calls
- Creates a new IntersectionObserver on every render
- Thinks takeRecords() re-measures the target's current state
- Hides the sentinel with display: none and wonders why nothing fires