when stale responses overwrite current results
Race conditions in async UI code happen when a request started later but answering faster overtakes a request started earlier but answering more slowly. Sequence numbers, AbortController and clean state handling prevent users from seeing stale data that no longer matches the current input.
Table of Contents
- 1. Why race conditions occur in async UI code
- 2. The classic example: a search field with stale responses
- 3. The sequence number pattern: only the newest request counts
- 4. AbortController as a network side addition
- 5. Closures and stale state as a second source of bugs
- 6. Race conditions in parallel state updates
- 7. Reproducing and testing race conditions deliberately
- 8. How frameworks partially solve the problem
- 9. Solution approaches compared
- 10. Summary
- 11. FAQ
1. Why race conditions occur in async UI code
Race conditions in async UI code occur because JavaScript gives no guarantee that multiple asynchronous operations started at the same time also complete in the order they were started. A request started later can respond earlier than a request already running before it, for example because the server responds faster to the second request or the network is briefly congested for the first one. Without an explicit safeguard, the UI code processes both responses in the order they arrive, not the order they were originally triggered.
This becomes especially visible in UI contexts because user interactions like typing, clicking, or scrolling typically trigger several asynchronous operations shortly after one another, before the first has even finished. Race conditions in async UI code are therefore not an edge case but the norm for any interaction that happens faster than the server can respond. A search field, an autocomplete widget, or a filter dropdown are the typical places where this problem first shows up, usually only in production with real users under varying network conditions.
2. The classic example: a search field with stale responses
The standard example of race conditions in async UI code is a search field that sends a new request to the server on every keystroke. If a user quickly types re, then rea, then react, three requests are started practically at the same time. If the server responds to re more slowly than to react for whatever reason, the stale response for re overwrites the already correctly displayed result for react once it arrives, because naive code simply writes every incoming response into state.
The result for the user is confusing and hard to reproduce: search results seem to jump back and forth randomly between different search terms, even though the user finished typing long ago. Because the timing depends on network latency, the bug rarely appears in local development environments with consistently fast response times and often only shows up in production, where network conditions vary.
// BUGGY: no protection against out-of-order responses
let searchResults = [];
async function onSearchInput(query) {
const response = await fetch(`/api/search?q=${query}`);
searchResults = await response.json(); // last response to arrive wins, not last query typed
renderResults(searchResults);
}
3. The sequence number pattern: only the newest request counts
The most robust pattern against race conditions in async UI code is a monotonically increasing counter that increments on every new request. Before processing a response, the code checks whether the sequence number that the request was started with still matches the most recently known sequence number. If the response is older than the most recently started request, it gets discarded, regardless of when it actually arrives.
This pattern works regardless of whether the underlying request is cancellable or not, because the decision is based purely on comparing numbers, not on actually stopping the network request. That makes the sequence number pattern the simplest and most reliable first line of defense against race conditions, even when a real network cancellation is impossible for some reason.
// FIXED: sequence number ensures only the latest request's response is applied
let latestRequestId = 0;
async function onSearchInput(query) {
const requestId = ++latestRequestId;
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
if (requestId !== latestRequestId) {
return; // a newer request has since been started, discard this stale response
}
renderResults(results);
}
4. AbortController as a network side addition
The sequence number pattern prevents stale responses from overwriting UI state, but it does not stop the actual network request running in the background. For many cases that is enough, but for expensive server operations, for example a costly database query per search request, it is worth additionally cancelling the stale request with AbortController instead of just ignoring it. That reduces server load and shortens the time until the server becomes free for the most recent request.
The combination of sequence number and AbortController is, in practice, the most robust solution against race conditions in async UI code: the sequence number reliably protects against overwriting UI state even if the cancellation fails for some reason or the server ignores it, while AbortController additionally avoids unnecessary server load and network traffic.
// Combine sequence number with AbortController for full protection
let latestRequestId = 0;
let currentController = null;
async function onSearchInput(query) {
const requestId = ++latestRequestId;
currentController?.abort(); // cancel the previous in-flight request
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal,
});
const results = await response.json();
if (requestId === latestRequestId) {
renderResults(results);
}
} catch (error) {
if (error.name !== 'AbortError') throw error;
}
}
5. Closures and stale state as a second source of bugs
A second, more subtle source of race conditions arises from closures that reference stale state. When an asynchronous callback function captures a variable from the surrounding scope before the state changes, the callback works after it resolves with the value that held at the time it was created, not the current value. In components with frequent state updates, that leads to situations where a callback mistakenly operates on an old version of a form field or counter.
The reliable protection against that is to never access captured variables directly inside an asynchronous callback, but instead to read the current state through a reference, for example a ref object in React or a reactive variable in Vue and Alpine, which always delivers the current value regardless of when the closure was created. This technique separates the moment the closure is created from the moment the value is actually accessed.
// BUGGY: closure captures the counter value at creation time
function startAutoSave(counter) {
setTimeout(() => {
saveToServer(counter); // stale value if counter changed before the timeout fired
}, 2000);
}
// FIXED: read the current value through a ref at call time
function startAutoSave(counterRef) {
setTimeout(() => {
saveToServer(counterRef.current); // always reads the latest value
}, 2000);
}
6. Race conditions in parallel state updates
Besides network responses, parallel writes to the same state can also create race conditions, especially when several asynchronous operations update the same state based on a previously read value. A classic example is a counter computed as the current value plus one, while two increments are triggered nearly simultaneously. If both operations read the same stale starting value before either one writes its result, one of the two increments is lost entirely.
The solution is to formulate state updates functionally, so every update explicitly builds on the actually current value instead of a previously read snapshot. In React, for example, the functional form of setState, which accepts the previous state as a parameter, solves this problem structurally, because the update is guaranteed to build on the state valid at the moment of actual execution, not on a possibly stale, previously captured value.
7. Reproducing and testing race conditions deliberately
Race conditions are notoriously hard to reproduce because they depend on the exact timing of two or more asynchronous operations. For reliable tests, it is worth explicitly controlling the order in which promises resolve instead of relying on real network latency. A test double that simulates controllable delays makes it possible to deliberately create the case where a request started later resolves before one started earlier.
A proven pattern is to equip the function under test with a mock implementation of fetch that adds a controllable delay for certain search terms. The test then deliberately resolves the second request before the first and checks whether the final UI state matches the result of the most recently started request, not the most recently resolved one. Such a test reliably fails as soon as the sequence number pattern is missing or implemented incorrectly.
// Test that deliberately resolves the older request after the newer one
test('discards stale search response', async () => {
const responses = {
re: delayedResolve({ items: ['react', 'redux'] }, 100),
react: delayedResolve({ items: ['react'] }, 10), // resolves first despite being newer
};
fetchMock.mockImplementation((url) => responses[extractQuery(url)]);
const search = createSearchController();
search.query('re');
search.query('react');
await flushAllTimers();
expect(getRenderedResults()).toEqual(['react']); // not the stale ['react', 'redux']
});
8. How frameworks partially solve the problem
Modern data fetching libraries such as React Query or SWR largely solve race conditions in async UI code automatically, by internally implementing sequence number like mechanisms and request deduplication already. Anyone using these libraries usually gets protection against stale responses without writing custom code, but must understand that this protection only applies to requests managed by the library, not to standalone, directly written fetch code outside that abstraction.
Frameworks like React, Vue, or Alpine.js themselves do not solve the problem automatically, because they have no built-in concept of request ordering. They only provide the building blocks, refs, reactive references, or effect cleanup functions, with which the sequence number pattern or a combination with AbortController can be implemented cleanly. The actual safeguard against race conditions always remains the responsibility of application code, not the framework itself.
9. Solution approaches compared
The following table compares the presented approaches by protective effect and effort.
| Approach | Protects UI state | Stops network request | Effort |
|---|---|---|---|
| No protection | No | No | None, but error prone |
| Sequence number | Yes | No | Low |
| AbortController alone | Mostly, except when cancellation is ignored | Yes | Low to medium |
| Sequence number + AbortController | Yes, doubly secured | Yes | Medium |
| Data fetching library | Yes, automatically | Yes, automatically | Introducing a dependency |
For most applications, the combination of sequence number and AbortController is the best trade off between reliability and control, without introducing an extra library.
Mironsoft
Robust UI architecture and error handling in JavaScript
Search results seem to jump around randomly?
We identify race conditions in your async UI code and implement the sequence number pattern, AbortController integration and clean state handling.
Diagnosis
Finding race conditions in search fields, filters and autocomplete
Implementation
Anchoring sequence numbers and AbortController consistently in your codebase
Testing
Building reproducible tests for race conditions into your CI pipeline
10. Summary
Race conditions in async UI code occur because responses are not guaranteed to arrive in the order their requests were started. The sequence number pattern is the simplest and most reliable defense, because it discards stale responses based on a simple numeric comparison, regardless of whether the underlying request can actually be cancelled.
Combined with AbortController, unnecessary network load can additionally be avoided. A second, more subtle source of bugs are closures that capture stale state instead of reading the current value through a reference. Anyone who applies both patterns consistently and tests race conditions deliberately with controlled delays significantly reduces one of the most common and hardest to debug classes of bugs in interactive UIs.
Avoiding race conditions in async UI code — the essentials at a glance
Sequence number
A monotonically increasing counter discards responses older than the most recently started request.
AbortController
Actually cancels stale requests, additionally reducing unnecessary server load beyond the sequence number.
Closures
Always read current state through a reference, never through a variable captured at creation time.
Testability
Controlled delays in mocks make race conditions deliberately reproducible instead of random.