Reliable IntersectionObserver infinite scroll is not achieved by wiring a callback directly to a fetch function. It requires an explicit contract for the scroll root, the lifetime of the sentinel element, request concurrency, cursor ownership, and the way later pages are merged. If any one of those boundaries remains implicit, the interface can load only once, issue duplicate requests, overwrite earlier pages, or merely imitate pagination while the data layer still reads everything.

This guide targets flat, ordered lists and an informational search intent. It explains browser intersection semantics, React lifecycle handling, a practical pagination state machine, and a reproducible local browser test. The examples use generated elements and do not depend on an external backend, external identity data, or irreversible data operations.

The Core Answer: Infinite Scroll Needs Four Separate Contracts

A dependable implementation fixes four boundaries: geometry, target lifecycle, request state, and data merging. IntersectionObserver supplies asynchronous intersection signals; it does not guarantee cursor correctness, prevent duplicate requests, preserve prior pages, or define global sorting for the application.

The geometry contract identifies the actual scroll root. A null root uses the top-level viewport. When a fixed-height element with overflow: auto performs the scrolling, that element should normally be the root. The sentinel belongs at the end of the list inside the relevant clipping chain, where its position can cross the root boundary as the user scrolls.

The lifecycle contract acknowledges that the target may not exist on the first render. A loading placeholder, an empty result, or conditional table markup can delay creation of the sentinel. Reading ref.current only once during mount leaves the observer with nothing to observe. A callback ref or an effect that depends on the actual DOM node allows observation to begin when the node appears and to move when the node is replaced.

The request contract treats notifications as signals rather than commands. Layout changes, scrolling, target movement, or newly appended content can schedule additional callbacks. The loading layer must guard with isLoading, hasNextPage, and the current cursor. It should not assume that one gesture produces exactly one callback.

The data contract distinguishes a fresh query from a later page. A fresh query replaces the collection and resets the cursor. A later page appends items and deduplicates them by a stable identifier. Small periodic updates should merge changed fields into existing records instead of replacing every page already loaded.

Keeping these contracts separate also improves diagnosis. A missing callback is usually a geometry or lifecycle problem. A repeated request is usually a request-state problem. Disappearing rows are usually a merge problem. Treating all three as “an IntersectionObserver bug” makes the system harder to reason about and test.

Configure the Root, Clipping Chain, and Sentinel Deliberately

When an inner container scrolls, set that element as the observer root and render the sentinel after the list inside it. Use a null root only for document scrolling; otherwise the observer and the user interface may operate in different coordinate systems.

Start with a simple layout: a root with a known height, ordinary list items in document flow, and a small sentinel as the last child. This baseline makes both element rectangles and scroll behavior easy to inspect. Add sticky headers, virtualization, and preload margins only after the baseline test passes.

The rootMargin option expands or contracts the root boundary used for intersection calculations. It can initiate loading before the sentinel reaches the visible edge, but it cannot repair an incorrect DOM hierarchy. Similarly, a threshold array introduces more crossing points; it does not alter which root owns the geometry. For a load-more trigger, threshold: 0 combined with an entry.isIntersecting check is a clear starting point.

A sentinel outside the scrolling container cannot represent progress through the container’s content. If that external element is already visible in the viewport, the initial notification may be the only meaningful transition. Scrolling an unrelated inner element does not force the external sentinel to cross the viewport boundary. Moving the target into the list is the correction; increasing thresholds is not.

Ancestor clipping is expected behavior. The specification’s intersection algorithm considers relevant clipping and the intersection root. A fixed-height container and a sticky header do not automatically invalidate infinite scroll, but they can change the portion that is geometrically visible. Test the actual layout instead of copying one universal preload distance.

For diagnosis, inspect three facts before rewriting application logic. First, confirm that root is either the intended element or null by design. Second, confirm that the observed target exists at the moment observe() runs. Third, compare the target and root rectangles while scrolling. The target should move across the applicable boundary. If it does not, the callback cannot solve the layout.

Also check whether content can grow enough to scroll. A short first page may leave the sentinel intersecting immediately. That is not necessarily an observer failure: the state machine may need to load another page until the root is filled or hasNextPage becomes false. Apply a bounded policy so an empty or malformed response cannot create an endless request loop.

Handle Conditional Rendering and Observer Lifetime in React

In React, model the currently observable DOM node as changing state rather than as a ref that is read once forever. Observe only after both root and target exist, replace observation when either node changes, and always pair observer creation with cleanup.

A callback ref can set sentinelNode whenever React attaches or detaches the element. An effect can then depend on sentinelNode, rootNode, and stable observer options. Inside the effect, construct an observer and call observe(sentinelNode) only when the required nodes are available. Return a cleanup function that calls disconnect() for an observer owned by that component.

This pattern handles several ordinary transitions: a skeleton becoming a table, an empty result becoming a populated list, a search replacing the list, and a load operation recreating the tail element. It also makes ownership visible. The component that constructs the observer is responsible for releasing it.

Keep the callback narrow. It should verify entry.isIntersecting, hasNextPage, and isLoading, then dispatch one load-next event. Cursor advancement, response parsing, error state, and item merging belong to the data layer. A callback that performs all of those tasks becomes sensitive to stale closures and is difficult to exercise independently.

Function identity matters because an effect that depends on a newly created callback can rebuild the observer on every render. Use a stable callback where practical, or store the latest state in carefully controlled refs while leaving the observer lifecycle stable. This is not an excuse to hide all state in refs; the goal is to separate current decision data from unnecessary observer reconstruction.

Cleanup prevents stale observers from retaining old targets and scheduling callbacks after the UI has moved on. disconnect() removes every target from one observer. unobserve(target) removes one target while leaving others attached. A component that owns one observer for one sentinel can use disconnect() in cleanup. A shared observer requires explicit per-target ownership and is a different design.

Development checks may expose effect setup and cleanup asymmetry more often than a simple manual path. The right response is to make setup and cleanup symmetric and the request layer idempotent. Turning off lifecycle checks only hides a race that can return when rendering timing changes.

Build a Pagination State Machine Instead of a Callback Chain

Define explicit states for a fresh search, loading the next page, success, empty data, failure, and exhaustion. Replace on a fresh query, append on later pages, deduplicate by a stable key, and reset cursor state whenever filters or sort order change.

Cursor pagination works well for changing lists, but a cursor is meaningful only in relation to a stable ordering. If the backend sorts by time and then by identifier, the continuation contract must include enough information to resume that order. Passing only the final identifier while ignoring the primary sort field can skip or repeat records.

Create one request builder for filters, order, and cursor. Search, load-next, and any safe offline export should not invent separate interpretations of the same query. One builder makes the contract reviewable and reduces drift when a filter changes.

The reducer or store update should define collision behavior. If a later page can include a record already present with fresher fields, merge those fields while preserving the intended order. If responses are immutable snapshots and duplicates are expected overlap, ignore the duplicate key. Never use the current array index as identity because appending, sorting, or removal changes it.

Periodic refresh creates a separate merge problem. A small refresh response should update matching records in the loaded collection and preserve records that were not part of the refresh. Replacing the entire collection with that small response discards earlier pages. Auxiliary option data follows the same rule: a fresh search may rebuild it, while a later page should merge and deduplicate it.

Error handling belongs in the state machine. Keep the last successful cursor until the next page succeeds. On failure, clear the in-flight flag and expose a controlled retry. Advancing the cursor before success can silently skip a page; leaving the loading flag set can permanently disable later loads.

Stop conditions must be explicit. hasNextPage should come from a documented response contract or a defensible page-size rule, not from the current visible row count. Once exhausted, the observer can remain attached if the callback exits cheaply, but detaching it makes the state easier to inspect and avoids unnecessary notifications.

Sorting also has a hard boundary. Client-side sorting can reorder only the items already loaded. It cannot claim to represent the complete list. If users need global ordering, send the sort field and direction to the backend and include them in the cursor contract. Infinite scroll should not mean reading an entire dataset and slicing it in the browser; that merely moves cost and hides the real query behavior.

Verification and Reproduction in a Local Browser Sandbox

The following sandbox creates a fixed-height root, verifies a false-to-true intersection transition after scrolling, observes a target created later, and confirms that disconnecting before the scroll prevents a later true notification. It makes no external data request.

Use Node.js 22.23.2, playwright-core 1.62.1, and a compatible Chromium 151 browser. Save the following file as verify-io.cjs in an empty test directory:

const { chromium } = require('playwright-core');
const assert = require('node:assert/strict');

async function makeCase(page, delayed = false, disconnectEarly = false) {
  return page.evaluate(async ({ delayed, disconnectEarly }) => {
    const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
    const waitUntil = async (predicate, timeout = 2000) => {
      const deadline = performance.now() + timeout;
      while (!predicate()) {
        if (performance.now() >= deadline) {
          throw new Error('Timed out waiting for observer state');
        }
        await wait(20);
      }
    };
    const root = document.createElement('div');
    root.style.cssText = 'height:120px;overflow:auto;width:300px';
    document.body.append(root);
    const states = [];
    const observer = new IntersectionObserver(
      entries => states.push(...entries.map(entry => entry.isIntersecting)),
      { root }
    );
    if (delayed) await wait(50);
    const spacer = document.createElement('div');
    spacer.style.height = '500px';
    const target = document.createElement('div');
    target.style.height = '10px';
    root.append(spacer, target);
    observer.observe(target);
    await waitUntil(() => states.length > 0);
    if (disconnectEarly) observer.disconnect();
    root.scrollTop = 500;
    if (disconnectEarly) await wait(300);
    else await waitUntil(() => states.includes(true));
    observer.disconnect();
    return states;
  }, { delayed, disconnectEarly });
}

(async () => {
  const executablePath = process.env.CHROME_PATH;
  assert(executablePath, 'Set CHROME_PATH to a compatible Chromium or Chrome executable.');
  const browser = await chromium.launch({ headless: true, executablePath });
  const page = await browser.newPage();
  await page.setContent('');
  const normal = await makeCase(page);
  assert(normal.includes(false) && normal.includes(true));
  const delayed = await makeCase(page, true, false);
  assert(delayed.includes(true));
  const stopped = await makeCase(page, false, true);
  assert(!stopped.includes(true));
  console.log('PASS rooted-scroll, delayed-target, disconnect');
  await browser.close();
})().catch(error => { console.error(error); process.exit(1); });
  1. In the empty directory, run npm init -y, followed by npm install --save-dev playwright-core@1.62.1. Set the CHROME_PATH environment variable to a compatible Chromium 151 or Chrome 151 executable.
  2. Run node verify-io.cjs. The pass condition is exit code zero and output containing PASS rooted-scroll, delayed-target, disconnect. An assertion, timeout, or browser-launch error is a failure, not a partial pass.
  3. Change observer.observe(target) to if (!delayed) observer.observe(target), then rerun the file. The ordinary scroll case should still pass, while the delayed-target assertion should fail. This negative control demonstrates why a conditionally rendered node must be observed after it exists.

Final editorial verification executed the corrected non-destructive test in the scheduler environment with Chrome 151.0.7922.174, Node.js 22.23.2, and playwright-core 1.62.1. To remove a timing-dependent failure caused by fixed sleeps, the positive cases now wait for the required observer state within a two-second bound. Five consecutive runs returned [false, true] for both the ordinary and delayed cases and [false] after early disconnect. The aggregate result passed. This was a functional contract test, not a performance measurement, so it does not support speed, resource, or success-rate claims.

The test intentionally isolates browser geometry from React. Once it passes, add a component test that toggles the conditional sentinel and asserts one load-next action. Then add a reducer test for replace, append, duplicate keys, failure, retry, and exhaustion. Separating these layers tells you whether a failure is geometric, lifecycle-related, or caused by data merging.

Applicability, Risks, and a Review Checklist

This design fits flat lists with a stable order. Do not apply it unchanged to recursive trees, drag-and-drop hierarchies, or views that require a complete global aggregate; identify the data invariants before choosing pagination.

A tree’s root count is not its total node count. Truncating roots can break parent-child context, move validation, and relationship calculations. A hierarchical interface may need level-by-level expansion, subtree loading, or a specialized server query. A sentinel designed for a flat sequence does not preserve those invariants.

Before release, review the implementation against this checklist:

  • The configured root is the element that actually scrolls, and the sentinel is the final in-flow child of the list.
  • A callback ref or node-dependent effect handles delayed creation and replacement of the target.
  • Every observer setup has matching cleanup, with ownership of disconnect() or unobserve() documented.
  • The callback checks intersection, in-flight state, and exhaustion before dispatching one load event.
  • A fresh query replaces data and resets the cursor; a later page appends and deduplicates by stable identity.
  • Periodic updates and auxiliary options merge into loaded state rather than erasing prior pages.
  • Global sorting is implemented by the backend and participates in the cursor contract.
  • Tests cover initial non-intersection, intersection after scrolling, conditional rendering, cleanup, duplicate pages, errors, retries, and the final page.

The durable conclusion is simple: treat IntersectionObserver as a geometry signal and pagination as a data state machine. Give each layer an explicit, testable contract, then connect them through one guarded load-next event. That structure remains understandable when layout, rendering timing, filters, and incoming records change. It also prevents a browser callback from becoming an accidental substitute for correct query, merge, and ordering semantics.