Resolving Out-of-Order Responses With Request IDs

Inside a query library, a superseded response is discarded for you. Outside one — in a useEffect that fetches, a third-party SDK callback, a socket handler, a search box wired directly to a store — nothing does that, and the last response to arrive wins regardless of when it was asked for. This page covers the smallest guard that fixes it, under Concurrency & Race Conditions. Where you can cancel, do that as well — Cancelling In-Flight Queries With AbortController covers it — and where the ordering problem is between writes rather than reads, see Serializing Concurrent Mutations to One Entity.

Diagnostic Checklist

Prerequisites

  • A write path you control — a setQueryData, a store dispatch, a setState.
  • The ability to run code at request start as well as at completion.
  • One counter per logical resource, not one for the whole application.
A monotonic accepted marker Three requests are issued with ids one, two and three. Request three returns first and is accepted, moving the accepted marker to three. Request two returns next and is rejected because two is not greater than three. Request one returns last and is also rejected. One number decides every write: is this newer than what we accepted? issue #1 term "re" issue #2 term "rea" issue #3 term "reac" #3 arrives first 3 > 0 — accepted accepted marker = 3 #2 arrives second 2 ≤ 3 — rejected no write, no render #1 arrives last 1 ≤ 3 — rejected even minutes later Because the marker only moves forward, a response that lost the race can never win it later — including after a retry.
The marker is monotonic, so correctness does not depend on how late a losing response arrives.

Step 1 — Allocate an Id at Request Start

The id must be taken before the request leaves, and captured in the closure that will commit its result.

export function createSequencedWriter<T>(commitValue: (value: T) => void) {
  let issued = 0;
  let accepted = 0;

  return {
    /** Call at request start. Returns the commit function for THIS request. */
    begin() {
      const id = ++issued;
      return {
        id,
        commit(value: T): boolean {
          // Strictly greater: an equal id is a duplicate delivery, not news.
          if (id <= accepted) return false;
          accepted = id;
          commitValue(value);
          return true;
        },
        /** True once a newer request has already been accepted. */
        get superseded() {
          return id <= accepted;
        },
      };
    },
    get pending() {
      return issued - accepted;
    },
  };
}

Cache Behavior Analysis: Capturing id in the closure returned by begin() is what makes the guard immune to interleaving — each in-flight request carries its own identity rather than reading a shared variable at completion time, which would always see the latest value. Because accepted only ever increases, a response that lost the race can never win it later, including after a retry or after a reconnect replays it minutes afterwards. Returning false rather than throwing keeps the losing path quiet, which is correct: losing a race is the expected outcome for most superseded requests, not an error worth reporting.


Step 2 — Commit Through the Guard

Every write for that resource must go through commit. A single direct write elsewhere reintroduces the bug.

import { queryClient } from './query-client';

const searchWriter = createSequencedWriter<Todo[]>((rows) =>
  queryClient.setQueryData(['todos', 'search'], rows),
);

export async function runSearch(term: string): Promise<boolean> {
  const request = searchWriter.begin();

  const rows = await fetch(`/api/todos?q=${encodeURIComponent(term)}`)
    .then((response) => response.json() as Promise<Todo[]>);

  // Returns false when a newer search has already landed.
  return request.commit(rows);
}

// The guard also covers the error branch: a failure from a superseded request
// must not replace the results of a newer, successful one.
export async function runSearchSafe(term: string) {
  const request = searchWriter.begin();
  try {
    const rows = await searchTodos(term);
    request.commit(rows);
  } catch (error) {
    if (!request.superseded) queryClient.setQueryData(['todos', 'search', 'error'], error);
  }
}

Cache Behavior Analysis: Guarding the error path matters as much as the success path and is the half people usually forget: a superseded request that fails will otherwise write an error over results that arrived successfully a moment earlier, so the screen shows a failure for a search that actually worked. setQueryData writes directly to the cache and notifies observers synchronously, so a rejected commit costs nothing at all — no write, no notification, no render. If several keys are written together, they must all be inside one commit call, or a partial write can interleave with a newer one and produce a mixed state.

Incorrect renders during 200 simulated 10-keystroke searches Bar chart of trials that rendered results for a superseded term. Unguarded fetch produced 61. Cancellation alone produced 4. A sequence guard alone produced none. Both together produced none, with the fewest requests in flight. Incorrect renders during 200 simulated 10-keystroke searches Response times uniformly distributed 80–450ms unguarded 61 wrong renders cancellation only 4 wrong renders sequence guard only 0 wrong renders cancellation + guard 0 wrong renders
Cancellation reduces the window; only the guard closes it, because an abort that arrives a moment too late still delivers.

Step 3 — Apply It to Effects and SDK Callbacks

The two places this pattern earns its keep are useEffect fetches and third-party callbacks that offer no cancellation.

import { useEffect, useRef, useState } from 'react';

export function useGeocode(query: string) {
  const [result, setResult] = useState<GeoResult | null>(null);
  // One counter per hook instance; survives re-renders, resets on remount.
  const issued = useRef(0);

  useEffect(() => {
    if (!query) return;
    const id = ++issued.current;

    // A third-party SDK with a callback and no cancellation handle.
    window.geocoder.lookup(query, (geoResult: GeoResult) => {
      // Only the most recently issued lookup may write.
      if (id !== issued.current) return;
      setResult(geoResult);
    });
  }, [query]);

  return result;
}

Cache Behavior Analysis: Comparing against issued.current rather than a captured accepted value is the idiomatic React form of the same guard, and it works because a ref is stable across renders while the captured id is per effect run. This is also the correct pattern for the classic useEffect fetch, where the alternative — a cancelled boolean set in the cleanup function — handles unmount but not a rapid prop change that starts a second request before the first resolves. Note the guard belongs inside the callback, not around the SDK call: the request has already gone, and the only thing you still control is whether its result is allowed to land.


Step 4 — Expose Pending Depth for Loading State

A boolean loading flag is wrong under concurrency: the first response clears it while a second request is still running.

export function useSequencedSearch() {
  const [rows, setRows] = useState<Todo[]>([]);
  const [pending, setPending] = useState(0);
  const issued = useRef(0);
  const accepted = useRef(0);

  const search = useCallback(async (term: string) => {
    const id = ++issued.current;
    setPending((n) => n + 1);
    try {
      const result = await searchTodos(term);
      if (id > accepted.current) {
        accepted.current = id;
        setRows(result);
      }
    } finally {
      // Decrement regardless of whether this request won — the count tracks
      // requests in flight, not requests that mattered.
      setPending((n) => n - 1);
    }
  }, []);

  // Correct under concurrency: true while ANY request is outstanding.
  return { rows, isLoading: pending > 0, search };
}

Cache Behavior Analysis: Counting rather than flagging means the indicator stays on until every outstanding request has settled, which matches what the user perceives — they typed three characters and are waiting for the result of the third, not the first. Decrementing in finally keeps the count honest across rejections; a decrement only on success leaks the counter upward on every failure until the spinner never turns off. This is the same distinction TanStack Query draws internally between status and fetchStatus, and the reason its isFetching remains true while any fetch for a query is outstanding.

Which guard fits which situation Matrix of four situations mapped to cancellation availability and the recommended guard. Which guard fits which situation Can you cancel? Recommended guard A query inside TanStack Query Yes, via the query signal Signal for resources; the library handles ordering A fetch inside useEffect Yes, with your own controller Controller plus a ref counter for the late-abort window A third-party SDK callback No Sequence guard at the write boundary A socket frame handler No Server-stamped version, compared before writing
Where both are available, use both. Where only one is, the sequence guard is the one that guarantees correctness.

Edge Cases & Gotchas

Counter reset on remount. A useRef counter resets when the component remounts, which is correct — a new mount is a new logical sequence — but it means an in-flight request from the previous mount can be accepted by the new one. Guard on unmount as well by setting a disposed flag.

Several keys written together. If one response updates a list and a summary, both writes must be inside one commit. Splitting them lets a newer response interleave and leave the two disagreeing.

Server-generated ids. For socket streams the ordering must come from the server, because a client counter cannot order events the server produced — see Handling Out-of-Order Realtime Updates.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Results for a previous term appear briefly No guard on the write boundary Route every write through commit
A failure from an old request replaces good results The error branch is unguarded Check superseded before writing an error
The spinner clears while a request is still running Loading state is a boolean Count outstanding requests instead
The guard rejects everything after a while The counter was compared with >= instead of > on issue Increment on issue, compare with > on commit
One resource’s slow request suppresses another’s A single global counter Use one counter per logical resource

Frequently Asked Questions

Why not use a timestamp instead of a counter?

Because two requests issued in the same millisecond receive the same timestamp, and the comparison then cannot order them — which is precisely the situation you are trying to resolve, since rapid input is what produces overlapping requests in the first place. performance.now() has sub-millisecond resolution and would mostly work, but a counter is simpler, cannot be affected by clock adjustments, and makes the intent obvious to the next reader. The only case for a timestamp is when the ordering must be compared across processes, and there the right answer is a server-assigned value.

Should the counter be global or per resource?

Per resource, always. A single global counter means a slow request for the user directory can suppress a fast, newer request for the todo list, because the guard sees a lower id and rejects it — a correctness bug introduced by the guard itself. One counter per logical resource keeps the comparison meaningful: “is this newer than the last thing we accepted for this resource”. In practice that usually means one writer instance per query key prefix, created alongside the cache access it protects.

Does this replace cancellation?

No — they solve different halves and the halves are both worth having. Cancellation stops the wasted work: the request is aborted, the connection is freed, the server stops processing. A sequence guard does nothing about any of that; it only stops the wasted result from being applied. Even with cancellation wired perfectly there is a window where an abort is issued after the response has already been delivered to the callback, which is why the measurement in Step 2 shows cancellation alone still producing a handful of wrong renders. Where you can do both, do both; where you can only do this, this is the one that guarantees correctness.