Blog
Race conditions in React: when an older request overwrites newer state
The filter says archived, but pending rows return. Control response ownership, cancellation, loading and errors, and test competing requests without relying on network timing.
The filter says “Archived”, but the rows are pending
A moderator is reviewing photo captions in a local-history archive. They open the pending queue, then switch to the archive before the first response arrives. Archived captions appear. A moment later, pending captions replace them, while the selected filter still says “Archived”. Acting on that list now means acting on a misleading screen.
This fictional feature illustrates a production correctness problem, not a reported GiSoft incident. Both responses can be valid. The fault is that the page accepts a result which no longer belongs to the current selection.
Moment Selection Completion and visible result
0 pending A starts
1 archived B starts
2 archived B completes: archived rows
3 archived A completes: pending rows overwrite them
With ownership checked at 3: A is ignored; archive stays visible.Start order does not determine completion order
A starts before B; that does not require A to finish first. A cache miss, a slower database query or changing mobile-network conditions can make the older operation take longer. This is about completion of asynchronous operations, not a claim that individual HTTP packets directly dictate React state.
Localhost, small fixtures and a developer waiting between clicks can hide the bug. Production users navigate and type while requests remain in flight. Network throttling helps investigation, but a reliable regression test should control the completion order directly.
The same problem occurs in event handlers, route loaders, custom hooks, mutation callbacks and shared caches. useEffect is one possible starting point for work; it is not the underlying cause. Several operations have been allowed to write the same state without an ownership rule.
A plausible implementation with no ordering rule
The example contract is a queue of caption summaries. These types belong to the article's fictional API; put them in queueContract.ts when trying the snippets together. The code targets the repository's React 19 and TypeScript 5.9 stack.
export type Bucket = 'pending' | 'archived';
export type QueueQuery = { bucket: Bucket; term: string };
export type Caption = { id: string; label: string };
export type QueueReader = (
query: QueueQuery, signal?: AbortSignal,
) => Promise<Caption[]>;
export type QueueView =
| { phase: 'idle' | 'loading' }
| { phase: 'ready'; rows: Caption[] }
| { phase: 'failed'; message: string };Here is an intentionally faulty hook, useUnsafeQueue.ts. Loading begins after a queue selection, not automatically on mount. Its read dependency will be the HTTP adapter shown later.
import { useState } from 'react';
import type { QueueQuery, QueueReader, QueueView } from './queueContract';
export function useUnsafeQueue(read: QueueReader) {
const [query, setQuery] = useState<QueueQuery>({ bucket: 'pending', term: '' });
const [view, setView] = useState<QueueView>({ phase: 'idle' });
async function search(next: QueueQuery) {
setQuery(next);
setView({ phase: 'loading' });
try {
const rows = await read(next);
setView({ phase: 'ready', rows });
} catch {
setView({ phase: 'failed', message: 'Could not load captions. Try again.' });
}
}
return { query, view, search };
}Each call captures its own next, but all calls can replace view. A late success can display the wrong rows; a late failure can hide a newer success. Adding a dependency to an Effect or wrapping this function in useMemo would not decide which completion is relevant.
Give the latest intent permission to commit
For this screen, a fresh queue selection or edit to the search text supersedes the previous intent. Give each intent a distinct identity and allow only that identity to publish a result. Comparing query strings alone is insufficient: the user can select pending, archived, then pending again. The first and third requests have the same parameters but belong to different interactions.
Ignoring a stale completion does not stop the work. It only removes that operation's right to update this view. That is useful even with an uncancellable client or a shared request whose result is still valuable to another cache subscriber.
The following useCaptionQueue.ts combines that rule with cancellation of unnecessary reads. The controller object doubles as a unique local ticket. commit() is the authority check; abort() is a separate request to stop work. The timer is for the autocomplete example below.
import { useEffect, useRef, useState } from 'react';
import type { QueueQuery, QueueReader, QueueView } from './queueContract';
export function useCaptionQueue(read: QueueReader) {
const [query, setQuery] = useState<QueueQuery>({ bucket: 'pending', term: '' });
const [view, setView] = useState<QueueView>({ phase: 'idle' });
const owner = useRef<AbortController | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => {
const previous = owner.current;
owner.current = null;
previous?.abort();
clearTimeout(timer.current);
}, []);
function search(next: QueueQuery, delay = 0) {
const previous = owner.current;
const ticket = new AbortController();
owner.current = ticket;
previous?.abort();
clearTimeout(timer.current);
setQuery(next);
setView({ phase: 'loading' });
function commit(value: QueueView) {
if (owner.current === ticket && !ticket.signal.aborted) {
setView(value);
}
}
async function run() {
try {
const rows = await read(next, ticket.signal);
commit({ phase: 'ready', rows });
} catch {
if (!ticket.signal.aborted) {
commit({ phase: 'failed', message: 'Could not load captions. Try again.' });
}
}
}
if (delay > 0) timer.current = setTimeout(() => { void run(); }, delay);
else void run();
}
return { query, view, search };
}Ownership changes synchronously when search() receives a new intent, before any debounce delay. Both success and failure go through the same check. Cleanup revokes ownership on unmount, including when a reader ignores cancellation. No isMounted flag can distinguish two competing requests while the component remains mounted.
This hook owns one view for one archive and caller context. It is not a general query cache. The reader and archive scope must remain coherent for that instance; changing archive or account requires invalidating the instance or explicitly starting a new scope. The query object is treated as a snapshot, not something another callback mutates.
Cancel obsolete reads without hiding genuine failures
Cancellation only reaches fetch if the adapter forwards the signal. Here is captionApi.ts. The example endpoint returns an array of { id, label } objects, with unique IDs; this small runtime check verifies the field types and selects the public fields. It is not a complete schema system.
import type { QueueReader } from './queueContract';
export const readCaptionQueue: QueueReader = async (query, signal) => {
const params = new URLSearchParams({ bucket: query.bucket, q: query.term });
const response = await fetch(`/api/caption-queue?${params}`, {
signal, credentials: 'same-origin', headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error(`QUEUE_HTTP_${response.status}`);
const payload: unknown = await response.json();
if (!Array.isArray(payload) || payload.some((row) =>
row === null || typeof row !== 'object'
|| typeof row.id !== 'string' || typeof row.label !== 'string'
)) throw new Error('QUEUE_SHAPE');
return payload.map((row) => ({ id: row.id, label: row.label }));
};The caller can supply readCaptionQueue to the component. This adapter uses a same-origin endpoint and the application's existing session contract; it does not establish authentication or relax access control.
With default browser abort behaviour, cancellation normally rejects with AbortError. Custom abort reasons and HTTP wrappers can differ. The hook checks the signal it owns because it knows why it was cancelled, rather than classifying every exception name as a harmless cancellation. A genuine failure of the current request still produces an error. A timeout intended to inform the user needs its own error policy, not silent treatment as obsolete work.
Aborting a browser request does not roll back a server transaction. The server may already have accepted a mutation. Even for reads, cancellation cannot promise that every database query or intermediary stopped. Reject stale UI writes whether or not cancellation saves resources.
Debounce limits work; it does not establish relevance
A moderator searches captions for “canal”: c, ca, can, cana, canal. A slower result for can must not replace the result for canal. Debounce answers when to start another request. Ownership answers which result may update the screen.
There is a particularly easy gap to miss: can is already in flight, the user types canal, and the old result returns during the new debounce interval. Waiting until the next fetch starts to invalidate the old request is too late. Our hook invalidates it at the input event.
This complete CaptionQueue.tsx uses immediate requests for queue buttons and an illustrative 180 ms delay for text edits. It deliberately clears previous rows while the new selection is pending. Keeping earlier rows can also be a product choice, but they must be identified as earlier results rather than presented as matching the new filter.
import type { QueueReader } from './queueContract';
import { useCaptionQueue } from './useCaptionQueue';
export function CaptionQueue({ read }: { read: QueueReader }) {
const { query, view, search } = useCaptionQueue(read);
return (
<section>
<button type="button" onClick={() => search({ ...query, bucket: 'pending' })}>
Pending
</button>
<button type="button" onClick={() => search({ ...query, bucket: 'archived' })}>
Archived
</button>
<output aria-label="Current queue">
{query.bucket === 'pending' ? 'Pending' : 'Archived'}
</output>
<input aria-label="Caption text" value={query.term}
onChange={(event) => search({ ...query, term: event.target.value }, 180)} />
{view.phase === 'loading' && <p role="status">Loading captions…</p>}
{view.phase === 'failed' && <p role="alert">{view.message}</p>}
{view.phase === 'ready' && <ul>
{view.rows.map((row) => <li key={row.id}>{row.label}</li>)}
</ul>}
</section>
);
}The hook's timer schedules real behaviour; tests need not wait for real time. Use a controlled test clock to verify debounce, and controlled promises to verify completion order. Serialising all searches behind the oldest one would avoid overlap at the cost of making every keystroke wait for an irrelevant result.
Loading and errors must have the same owner as data
Suppose A starts, then B starts. A finishes while B remains pending. An unconditional finally { setLoading(false) } would hide the loading indicator for B. In the corrected hook, an old operation cannot commit any phase; the current view stays loading until its own operation completes.
For an unrelated dashboard that genuinely loads three independent panels, a pending-operation count or separate state per panel may be more appropriate. A single “latest request wins” rule would incorrectly discard useful results there.
Errors have the same race. B may succeed before A fails. That late failure must not replace valid archived rows with “Could not load”. Treat data, error and loading as one coherent operation state. Updating them independently without checking ownership recreates the bug in a less obvious form.
Navigation, dependent reads and captured values
A request from the previous route can finish after navigation. Unmount cleanup covers this hook's local state, but a persistent layout or shared store may outlive the page. Include the archive, selected entity, filter and relevant caller scope in the ownership or cache identity. Revoke the old scope when navigation changes its meaning; a pathname change does not universally mean a component unmounted.
Dependent requests need the same discipline. If choosing another photograph first loads its metadata and then its captions, the whole chain must retain that photo's identity. Check relevance before starting the dependent read and before publishing it, and propagate cancellation where supported.
Callbacks see values captured by their render. Calling setSelectedPhoto(next) and then loadCaptions(selectedPhoto.id) can still use the previous ID. Pass next.id explicitly for this action. A functional state updater can help with calculations based on previous state; it cannot decide whether a server response belongs to the selected photograph. Suppressing a dependency-lint warning does not repair either problem.
Optimistic changes need a different agreement
A moderator changes a caption's review priority from normal to high, then to urgent. The UI applies both immediately. If the response for high arrives last and replaces the row, the interface moves backwards. A blanket rollback after the first mutation fails can likewise undo the later edit.
Possible designs include a version per resource mutation, a pending-operation ledger, or sequencing edits to the same resource. Rollback should affect the operation it belongs to, not restore an entire old snapshot over newer work. A cache library may supply part of this machinery, but its mutation semantics still need to match the feature.
Ignoring the older response protects the display, not the server's final state. Refreshing immediately after the latest client mutation is insufficient if an earlier write can still commit afterwards. Reconcile after the relevant writes settle, or rely on server versions and a defined conflict protocol. Do not blindly retry an old payload over another user's accepted change.
Aborting, retrying and applying a business operation are different things
Disabling a submit button reduces accidental double clicks. It cannot coordinate two tabs, two components or another user. Duplicates may also come from explicit client retries, a refresh, configured proxy retries or backend redelivery; their behaviour depends on the system rather than a universal browser rule.
Consider POST /api/captions/28/approve. The user navigates away and the browser stops waiting. Approval may already have committed and scheduled a notification. Before retrying an uncertain outcome, consult the operation result where the API provides one and follow its retry contract.
Distinguish three identities. A request ID correlates one transport attempt. A view-intent ID identifies the selection that currently owns the screen. A business operation ID/idempotency key identifies one logical approval across retries. A fresh view ticket is not a suitable replacement for a stable retry key. The backend must enforce key scope, payload consistency and duplicate handling.
For concurrent edits, a caption read at revision 12 may already be at 13 when saving. An expected-version check or conditional update can reject the obsolete write; the API defines the conflict response. Permissions, uniqueness and atomic database changes remain server responsibilities. Frontend cancellation cannot replace them. Detailed redelivery mechanisms belong in the existing GiSoft Messenger article.
Choose one owner for the screen's state
If the URL owns the filter, local controls and the query key should reflect it. If a form owns an unsaved edit, a background refresh must not silently overwrite it. A query cache can own server snapshots, while the backend remains authoritative for committed business state. Duplicating the same rows independently in Context, component state and a cache makes reconciliation harder.
A query library may provide deduplication, cache identity, cancellation and mutation tools. None makes an incomplete key safe: pending and archived results must not share an undifferentiated cache entry. The repository does not declare TanStack Query or SWR, so these examples add neither. Use an existing library's supported mechanisms when one already owns the data.
For Next.js App Router, coordinate client filters with route parameters and server refreshes. A route refresh can preserve unaffected client state and does not itself invalidate server-side caches. Behaviour depends on the routing mode, version and cache configuration. These client snippets belong within a client subtree; provide read from client code, not as an ordinary function passed through a Server Component boundary.
Transitions concern rendering priority; manually coordinated asynchronous work still needs an ordering rule. Higher-level Actions may provide their own ordering semantics. Do not assume that wrapping an arbitrary fetch in startTransition makes the last user action win.
Make the reverse completion order a deterministic test
We need to choose when each result becomes available. ReplyGate.ts is an illustrative test helper created for these examples, not a Vitest or React API. It can resolve or reject one promise:
export class ReplyGate<T> {
private accept!: (value: T) => void;
private decline!: (reason: Error) => void;
readonly result = new Promise<T>((resolve, reject) => {
this.accept = resolve;
this.decline = reject;
});
deliver(value: T) { this.accept(value); }
fail(reason: Error) { this.decline(reason); }
}The test below uses Vitest, React Testing Library, user-event and a DOM environment, with @testing-library/jest-dom/vitest loaded in setup. The file imports the component and contract shown above. The reader double deliberately ignores cancellation: this verifies the ownership guard even when an underlying operation still completes.
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { CaptionQueue } from './CaptionQueue';
import type { Caption, QueueReader } from './queueContract';
import { ReplyGate } from './ReplyGate';
it('keeps the archive visible when the pending queue arrives late', async () => {
const inbox = new ReplyGate<Caption[]>();
const archive = new ReplyGate<Caption[]>();
const read = vi.fn<QueueReader>((query) =>
query.bucket === 'pending' ? inbox.result : archive.result,
);
const user = userEvent.setup();
render(<CaptionQueue read={read} />);
await user.click(screen.getByRole('button', { name: 'Pending' }));
await user.click(screen.getByRole('button', { name: 'Archived' }));
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls[0][1]?.aborted).toBe(true);
await act(async () => {
archive.deliver([{ id: 'caption-28', label: 'Canal footbridge' }]);
});
expect(screen.getByLabelText('Current queue')).toHaveTextContent('Archived');
expect(screen.getByText('Canal footbridge')).toBeVisible();
await act(async () => {
inbox.deliver([{ id: 'caption-11', label: 'Tram stop caption' }]);
});
expect(screen.getByText('Canal footbridge')).toBeVisible();
expect(screen.queryByText('Tram stop caption')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});The checkpoints matter: first establish that B is visible, then deliver A and check that B remains. Checking only the final filter label would miss corrupted rows. No real network latency or arbitrary sleep determines the order.
Cover adjacent races at the cheapest reliable level
Use the same gates for three focused cases. For loading, deliver A while B is unresolved and assert that the loading status remains; deliver B and assert it disappears. For errors, deliver B, reject A and verify that B remains visible with no alert. For cancellation, have the adapter double reject when its signal is aborted, then confirm no generic failure appears and B can still succeed. Also test unmount cleanup without relying on historical React warning messages.
For autocomplete, advance a fake clock deliberately: start can, enter canal, resolve can before the new timer fires and ensure it is not shown. Advance to the configured delay and deliver the current result. An additional pending → archived → pending case guards against mistakenly treating equal query parameters as equal intent.
Risk Useful test boundary
Ownership/reducer logic Unit, if separately extracted
Late rows, errors, loading React component
Signal and response mapping Adapter / contract
Repeated approval or edit Backend functional / integration
Real moderation journey Selected browser testA unit test is worthwhile for a pure coordinator or reducer; do not extract one merely to increase the test count. Adapter tests should check signal forwarding, response validation, error-code mapping and any revision or idempotency headers in the real contract. They do not prove that backend deduplication works; that requires server-side tests, including concurrency where relevant.
A browser test can verify the real feature with controlled intercepted responses where existing tooling supports this. Keep exact ordering cases mostly in component tests. Broad timing tolerances and arbitrary millisecond delays make poor substitutes for explicit response control. The GiSoft articles How to test Next.js architecture, failures and user workflows and How to test Symfony APIs with Pest cover those wider boundaries in detail.
Diagnose the sequence before changing the scheduling
Record enough bounded metadata to reconstruct intent and completion: local sequence, request correlation ID, safe query category or redacted key, route scope, start/end times and whether the result was applied or discarded. Include a response revision or business operation ID where relevant. A discarded old result can be healthy behaviour, not a server error.
Search text, photo descriptions and account data may be sensitive. Do not log credentials, tokens, full payloads or personal details just to diagnose ordering. Browser request timing paired with local sequence numbers is often more informative than a dump of response bodies.
Several tempting fixes address another concern. A longer debounce reduces calls but leaves overlap possible. useMemo does not order promises. isMounted distinguishes lifecycle, not concurrent intents. Unconditional finally updates can clear another request's loading state. Disabling a button is useful UX, but not backend idempotency. Aborting every mutation can discard knowledge of an already committed result. Each tool needs a specific responsibility.
A short review before shipping
- Name the current intent and the state it is allowed to replace.
- Invalidate older ownership when that intent changes, including during debounce.
- Apply one relevance rule to rows, loading and errors; cancel reads where useful.
- Decide how uncertain mutations, retries and concurrent edits are reconciled by the backend.
- Test reversed completion, late failure and loading overlap with controlled results.
The response that finishes last has no automatic right to own the screen. Make that right explicit, then protect business effects separately on the server.
Technical references
The fictional feature and examples were developed for this article. Framework and cancellation details were checked against:
- React, Effect cleanup: https://react.dev/reference/react/useEffect
- React, asynchronous Transition ordering: https://react.dev/reference/react/useTransition
- MDN, AbortController semantics: https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
- Next.js App Router, refresh behaviour: https://nextjs.org/docs/app/api-reference/functions/use-router
