cswork / libraryOpen practice workspace

Articles

IntersectionObserver Interview Questions: Infinite Scroll, Duplicate Fetches, and Cleanup

Practice IntersectionObserver infinite-scroll interviews with root and margin settings, in-flight deduplication, cursor safety, cleanup, and end-of-list tests.

An infinite feed loads page two twice when its sentinel reaches the viewport. Then the user changes filters, an old response arrives, and the wrong items appear in the new feed. The IntersectionObserver interview question is not merely how to observe a DOM node. It is how to make visibility a request trigger while cursor ownership, in-flight deduplication, completion, and cleanup keep the data correct.

The browser mechanics here follow MDN's Intersection Observer API guide, its observer reference, and the W3C specification. The feed contract, request timeline, and tests are original practice, not a report of an employer's interview.

What does the observer report?

IntersectionObserver watches whether an observed target intersects a root's area according to configured thresholds and margins. It queues callbacks rather than requiring a scroll handler to measure rectangles continuously. A callback receives entries describing intersection changes; it is not a command to fetch data. The application decides whether an intersecting sentinel should request another page.

The default root: null uses the viewport. If the feed scrolls inside a dedicated panel, set root to that scroll container when appropriate; MDN says a non-null root must be an ancestor of the observed target. rootMargin expands or shrinks the root's effective rectangle, so a positive bottom margin can start loading before the sentinel is visibly at the bottom. threshold controls which intersection ratios trigger notifications. For an ordinary sentinel, checking entry.isIntersecting is clearer than treating every callback as “visible.”

An observer can deliver an initial entry soon after observation begins, and later entries when relevant thresholds are crossed. Do not assume one callback equals one page, or that callbacks happen only after explicit user scrolling. The sentinel may already be inside a tall viewport on first render. The implementation must be correct under repeated and early notifications.

Where does duplicate fetching come from?

Imagine the sentinel remains near the bottom while a response is pending. Layout changes, root-margin geometry, a re-created observer, or a repeated initial observation can produce another actionable entry. If the callback reads an outdated loading state from a closure, it may start another request for the same cursor. Even with a perfectly stable observer, a programmatic retry or filter change can race the in-flight request.

The first gate is a synchronous in-flight guard established before starting the request. A mutable ref or a request coordinator can enforce it across callbacks without waiting for a React state update. The second gate is cursor identity: the page request should be associated with the exact feed key and cursor it is meant to advance. A server response for (query='cats', cursor='p2') must not append to a feed now showing (query='dogs', cursor='p1').

A unique request key such as (filterSignature, cursor) can help deduplicate within the client. Do not infer that the server's cursor itself is globally unique across filters or data versions. If an API returns overlapping records across pages, stable item IDs and an explicit merge policy may also be needed. That overlap is a pagination/data-contract issue, not something the observer can repair.

A request sequence to explain at the whiteboard

The following is an original trace. It assumes a cursor API returns { items, nextCursor }, with nextCursor: null meaning no more pages. The observer owns only the trigger. The feed controller owns the request and result state.

MomentObserver or user eventRequest gateCorrect data action
Sentinel first intersectsEntry says isIntersectingNo request in flight, cursor p2Reserve (filter A, p2) and request once
Another intersection arrivesRepeated actionable entrySame request still in flightIgnore; do not request p2 again
User changes to filter BFeed identity changesOld A request still pendingAbort where possible; invalidate old generation
Old A response arrivesNo new observer event neededGeneration mismatchDo not append A items to B
B returns last pagenextCursor is nullhasMore falseStop observing or ignore future entries

This table does not imply the browser guarantees a particular order of entry delivery relative to network responses. It defines what the application should do under any ordering. That makes it a useful interview test rather than a timeline that only passes in one happy path.

How would you structure the loading guard?

A small conceptual implementation starts with a feed generation, current cursor, hasMore, and a synchronous in-flight marker. The observer callback checks that the entry is intersecting, a next page exists, and no request is in flight. It reserves the request key before awaiting. On success, it verifies that the feed generation is still current, appends or merges items, and advances to the returned cursor. In finally, it clears only the marker belonging to that request.

function onSentinelVisible(entry) {
  if (!entry.isIntersecting || !hasMore.current) return;
  if (inFlight.current) return;

  const request = {
    generation: generation.current,
    cursor: nextCursor.current,
  };
  inFlight.current = request; // reserve synchronously
  loadPage(request);           // handles response and finally cleanup
}

This is pseudocode, not a drop-in React hook. A complete implementation must handle error UI, cancellation, unmount, a changed sentinel node, and cursor initialization. A plain boolean is easy to reset incorrectly: an older request's finally may clear the guard for a newer request. Compare request identity before clearing. When filter B replaces A, increment a generation or use an equivalent ownership token so A's response cannot mutate B's state.

An AbortController can avoid wasted fetch work when the HTTP client supports it, but abort alone is not a full stale-response proof. A response may complete just as cancellation occurs, or a data layer may have its own cache and callback. Check ownership when committing the result. Distinguish “request was canceled” from “request result is no longer relevant.”

When should the observer be attached and cleaned up?

Create the observer when the relevant root, sentinel element, and callback contract are ready. Observe the current sentinel; when the component unmounts or those dependencies change, unobserve that target or disconnect the observer. MDN's instance methods distinguish unobserve(target) from disconnect(), which stops observing all targets. For one sentinel, either may suit cleanup; for a shared observer watching multiple targets, disconnecting all of them can be too broad.

If React rerenders and a callback function changes identity every time, an effect may repeatedly disconnect and reconnect. That can produce new initial entries and mask a duplicate-fetch bug. Keep ownership stable or make the request guard robust against reattachment. If the sentinel DOM node itself changes after pagination, attach to the new node and release the old one. A stale ref to a removed node will not load the next page.

Network cleanup is separate from observer cleanup. Disconnecting stops future intersection notifications; it does not cancel a fetch already started or prevent its completed promise from appending stale items. On unmount or filter switch, invalidate the request generation and abort if supported. An interview answer that calls only observer.disconnect() has solved half the race.

What if the sentinel remains visible after append?

A short feed may not fill the viewport. After one page appends, the sentinel can still be visible, but a threshold crossing may not happen again in the way the implementation expects. A robust design must choose a bounded way to continue filling: re-observe the moved sentinel, check visibility after layout, or offer a Load more control. Conversely, a careless effect that sees it visible and immediately loops can drain every page or hammer a failing endpoint.

Define a budget such as one new page per completed request cycle and stop when nextCursor is null, a request fails, the feed identity changes, or a reasonable automatic-fill limit is reached. Then expose a manual action if needed. That cap is an editorial design choice, not a browser standard. It makes the product behavior predictable even when a large display shows the whole initial feed.

Error handling also needs a deliberate rearm rule. If page three fails, keep the already loaded pages, show a retry control, and avoid restarting page three repeatedly while the sentinel remains visible. A user-triggered retry can reuse the same cursor after the error is visible. If the API has rate limits or backoff requirements, follow them. The observer's only job is to notice geometry; it should not implement server retry policy implicitly.

How do root, margin, and threshold affect behavior?

Use root: null for viewport scrolling, or the feed's scroll element for an inner scroller. A positive bottom rootMargin prefetches earlier; too large a margin can make the sentinel intersect immediately. A threshold of 1.0 waits for the entire sentinel to intersect, which may be impossible for a tall target. For a small sentinel, a low threshold plus isIntersecting is usually easier to reason about. Test in the actual scroller, because CSS overflow and nested clipping change the geometry.

The observer does not tell you the user's scroll intent or guarantee that the item is unobscured. Intersection is a geometry signal. It does not replace accessible pagination, server cursors, or list virtualization. A design may provide a Load more button for keyboard and screen-reader users or for failure recovery, while keeping observer-triggered loading as an enhancement. Decide how focus behaves when new items append; do not steal focus on every automatic page load.

Which tests prove the design?

Mock or control intersection entries and network responses independently. Fire two intersecting entries before the first promise resolves and assert one request for cursor p2. Resolve that request, append items, then allow the next cursor to load once. Reverse the order of filter A and B responses and assert that A never mutates B. Unmount before a response and assert no stale commit. These tests target ownership rather than browser timing luck.

Add geometry and terminal-state checks in a browser: default viewport root versus an inner scroll root, positive margin prefetch, initial sentinel visibility, a last page with nextCursor: null, and a failed page that does not retry forever. If a page returns duplicate item IDs, verify the product's merge policy. If a page returns zero items but another cursor, decide whether to continue automatically, present an empty-state warning, or stop after a bounded number of empty pages.

A unit test that calls the callback once is too weak. The interview's essential cases are duplicate notifications, stale responses, and end-of-list cleanup. Record the requested cursor sequence and rendered item IDs; a merely correct request count can hide duplicates in the displayed list.

A concise interview answer

“I would observe a small sentinel with the correct viewport or scroll root and use isIntersecting as a trigger, not as proof that a page should always be fetched. I would reserve the current (filter, cursor) synchronously before awaiting so duplicate entries cannot start the same request. Each response would verify that its feed generation still owns the view before appending and advancing the cursor. On filter change or unmount, I would disconnect the observer and invalidate or abort in-flight work. I would stop at a null next cursor, handle short-viewport fill with a bounded policy, and test repeated entries plus reversed network responses.”

That answer distinguishes the official browser contract from application pagination and retry policy. There is no candidate-report claim here; the scenario is a reproducible engineering exercise.

Sources and further reading