A sentinel is not a button. It reports its position repeatedly, and a fetch started from it will be started again unless something explicitly says otherwise. That something has to be request state, not an assumption about how often the callback runs.

Problem / Scenario Context

An infinite list appends twenty rows per page. Testing on a trackpad it works; on a mouse wheel, or on a phone with a fast flick, the network panel shows page 2 requested three times and page 3 skipped, and the list ends up with duplicated rows and a gap.

The sentinel fired three times in quick succession — once as it entered the rootMargin band, once as it fully entered, once after the previous append briefly moved it — and each call started a request with the same page value, because the counter is only incremented when a response arrives.

The instinct is to debounce the callback. That reduces the frequency without removing the race: two calls 300 ms apart still both start a fetch if the first has not resolved.

Mechanics Explanation

Three separate things must be true before a page request is legitimate, and each corresponds to a distinct piece of state:

  • No request is in flight. A boolean, set synchronously before the fetch begins.
  • There are more pages. A flag set when a response returns fewer rows than the page size.
  • The last attempt did not fail unrecovered. An error state that requires an explicit retry rather than silently re-firing on the next scroll.

The critical word is synchronously. Setting the flag inside a promise callback, or in React state, leaves a window in which a second callback sees the old value. Since observer callbacks for a burst are delivered in the same task, that window is exactly where the duplicates come from.

Incrementing the page counter before the request rather than after removes the second half of the problem: even if two requests do slip through, they ask for different pages.

The Window Between the Call and the FlagTwo sequences of three sentinel callbacks arriving in one burst. When the loading flag is set inside the promise resolution, all three callbacks read the old value and all three start a request for the same page. When the flag is set synchronously before the fetch, only the first callback passes the guard and the other two return immediately.flag set after await — three requests for page 2cb 1cb 2cb 33 identical requests in flightflag finally set — too lateflag set synchronously — one requestcb 1cb 2cb 3one request; callbacks 2 and 3 returned immediately

Comparison Table: Guard Strategies

Strategy Stops duplicates Notes
Debounce the callback no reduces frequency; the race survives
loading flag set after await no the window is exactly where the burst lands
loading flag set synchronously yes the minimum correct guard
Increment the page before fetching yes, and de-duplicates two escapees ask for different pages
Track in-flight page numbers in a Set yes also survives out-of-order responses
unobserve while loading yes costs a re-observe, and can miss a fast scroll

Minimal Reproducible Example

TypeScript
let page = 1;
let loading = false;

const io = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting) return;
  if (loading) return;                       // read here…
  const data = await fetch(`/items?page=${page + 1}`).then((r) => r.json());
  loading = true;                            // …set here: three callbacks all passed the check
  page += 1;
  append(data);
  loading = false;
});

Production-Safe Solution

TypeScript
type Status = 'idle' | 'loading' | 'error' | 'exhausted';

const PAGE_SIZE = 20;
let status: Status = 'idle';
let nextPage = 1;
const inFlight = new Set<number>();
let controller: AbortController | null = null;

async function loadNextPage(): Promise<void> {
  if (status !== 'idle') return;             // synchronous guard, first line
  const page = nextPage;
  if (inFlight.has(page)) return;

  status = 'loading';
  inFlight.add(page);
  nextPage += 1;                             // claim the page BEFORE awaiting anything
  controller = new AbortController();

  try {
    const res = await fetch(`/items?page=${page}`, { signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const rows: unknown[] = await res.json();

    append(rows);
    status = rows.length < PAGE_SIZE ? 'exhausted' : 'idle';
    if (status === 'exhausted') observer.unobserve(sentinel);   // nothing left to ask for
  } catch (err) {
    if ((err as Error).name === 'AbortError') { status = 'idle'; nextPage = page; return; }
    status = 'error';
    nextPage = page;                         // give the page number back for an explicit retry
    showRetryButton();
  } finally {
    inFlight.delete(page);
    controller = null;
  }
}

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) if (entry.isIntersecting) void loadNextPage();
}, { rootMargin: '400px 0px' });

observer.observe(sentinel);

// teardown: abort anything in flight so a resolved response cannot append to a dead list
export function dispose(): void {
  controller?.abort();
  observer.disconnect();
}

Four decisions in that code are worth naming.

status !== 'idle' as the first line. It covers loading, error and exhausted in one check, so no code path can start a request from a state that should not.

Claiming nextPage before the await. Even if a second call somehow passes the guard, it asks for a different page — the duplicate becomes a gap-free over-fetch rather than corrupted data.

Giving the page number back on failure. Without it, a failed request permanently skips a page, which shows up as missing rows nobody can explain.

Aborting on teardown. A response that resolves after the component is gone will otherwise append to a detached list, retaining it — the retention path that a WeakMap cannot help with.

Under React Strict Mode the effect runs twice in development, so loadNextPage is called twice. The synchronous guard handles it correctly — the second call returns immediately — which is the same property that makes it correct in production, as described in fixing doubled observer callbacks.

Four States, and Which Ones Permit a RequestA state machine with idle, loading, error and exhausted. Only idle permits a request. Loading returns to idle on a successful page with more remaining, moves to exhausted on a short page, and moves to error on failure. Error only returns to idle through an explicit retry, never through another sentinel crossing.idlerequests allowedsentinelloadingfull page returnedshort pageexhaustedunobserve the sentinelfetch failederrorexplicit retry onlyA sentinel crossingnever leaves error.

Verification Steps

  • Throttle the network to Slow 3G and flick-scroll: exactly one request per page should appear.
  • Check page numbers are contiguous in the network panel — a gap means the error path is not returning the page number.
  • Force a failure with an offline toggle and confirm the list stops rather than re-firing on every subsequent scroll.
  • Unmount mid-request and confirm the request is aborted rather than resolving into a detached list.
  • Run in Strict Mode and confirm the doubled call produces one request.

What the Network Panel Should Look LikeTwo request waterfalls for the same scroll session. The unguarded version shows page two requested three times with page three missing entirely, producing duplicated rows and a gap. The guarded version shows pages two, three and four requested once each, in order.unguardedpage=2page=2page=2duplicated rows, and page 3 never requestedguardedpage=2page=3page=4Contiguous page numbers, one request each, is the whole acceptance criterion.

Common Mistakes to Avoid

  • Debouncing instead of guarding. Reduces the frequency of the race without removing it.
  • Setting the flag after await. Leaves the exact window the burst arrives in.
  • Incrementing the page after the response. Means concurrent requests ask for the same page.
  • Retrying automatically on error. Turns a failing endpoint into a request loop driven by scrolling.
  • Leaving the sentinel observed after exhaustion. Harmless but wasteful; unobserve costs one line.

FAQ

Why does the sentinel fire several times for one scroll?

Because it crosses the observation boundary more than once. A generous rootMargin means it enters the expanded box, then the viewport, and appending the previous page can move it again. Each crossing is a legitimate entry; the guard is what turns three entries into one request.

Is debouncing the callback enough?

No. A debounce lowers how often the callback runs but does nothing about the state it reads. Two calls separated by three hundred milliseconds will both start a request if the first has not yet resolved, which on a slow connection is exactly the situation you are trying to survive.

Why increment the page counter before fetching rather than after?

So that two calls which somehow both pass the guard ask for different pages. Incrementing after the response means concurrent requests share a page number, which produces duplicated rows; incrementing before turns the worst case into a harmless over-fetch.

What should happen after a failed page request?

Stop, and require an explicit retry. Returning to the idle state means the next scroll immediately re-fires the same failing request, turning a broken endpoint into a request loop driven by the reader's scrolling. Give the page number back so the retry asks for the right one.


↑ Back to Infinite Scroll & Pagination