When arrival order lies: enforcing latest intent
When multiple requests are in-flight, responses can arrive out of order. Without protection, an older (slower) response can overwrite the user’s latest intent.
Break it: out-of-order responses overwrite UI
Type quickly to create overlapping requests. Requests complete with variable latency. Sometimes an older response arrives last and overwrites the latest intent.
Buggy (under test)
Buggy
out-of-order overwrites UI—
—
Detected issue
- Type fast enough to overlap requests (e.g., “r” → “re” → “rea”).
- Requests complete with variable latency, so an older request can finish last.
- Watch results show an older query after you typed a newer one
fakeSearch(query).then((res) => {
setResults(res); // ❌ commits whichever response arrives last
});Why this breaks
Mental model: concurrency breaks arrival order
Concurrency turns time into a correctness problem.
Time is now part of your state model.
Arrival order is not causality.
Correctness must be invariant to timing.
Invariant: UI must reflect the latest intent (latest input), not the latest arrival.
Arrival-driven commits allow obsolete intent to win.
This is a causality violation — the system applies effects out of order.
Requests overlap. Completion order becomes nondeterministic under concurrency.
If you commit whatever arrives last, your UI becomes “latest arrival wins”, which is not what the user wants.
This class of bug is extremely common in search boxes, filters, autosuggest, and collaborative UIs.
Reference (correct mental model)
Latest intent wins
Wrong mental model
// ❌ "latest arrival wins"
fakeSearch(query).then(setResults);Correct mental model
// ✅ "latest intent wins"
startRequest();
ignoreOrCancelOlder();Fix
Minimal reliable fix: assign each request an incrementing id and only apply the result if it matches the latest request.
Correctness must not depend on network timing.Fixed: latest wins (requestId gate)
Fixed
latest wins (requestId gate)—
—
const id = ++requestIdRef.current;
fakeSearch(query).then((res) => {
if (id !== requestIdRef.current) return; // ✅ ignore stale response
setResults(res);
});Optional
Instead of only ignoring stale responses, you can cancel old requests. With real fetch, pass signal.
AbortController (cancel old requests)
Abort
cancels old requests (AbortController)—
—
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(err => {
if (err.name === "AbortError") return;
throw err;
});Key takeaways
- Overlapping requests can finish out of order.
- UI should reflect latest intent, not latest arrival.
- Fix with gate (requestId) or cancel (AbortController).
Approach & tradeoffs
Approach
- Assume concurrency: completion order is nondeterministic.
- Pick a policy: gate (ignore stale) vs cancel (abort old).
- Make the policy explicit in code, not implicit in timing.
Tradeoffs
Gate (requestId)
- ✅ Simple. Works with any async primitive.
- ⚠️ Prevents stale commits but does not stop work.
Cancel (AbortController)
- ✅ Stops old work in real fetch.
- ⚠️ Requires abortable API + abort handling.
Assuming the latest response reflects the latest user intent.
It rarely does under concurrency.