Everyday Programmer
Work with Jimmy
Systems

The Bug Isn't the API Response. It's the Order It Arrived In.

Two valid responses can still produce the wrong screen. Give requests explicit ownership, guard error and loading updates, and test the order they finish in.

7 min readReact, frontend architecture, async state, testing, TanStack Query
Isometric search system with two request capsules: a teal capsule passes an ownership gate and lights the current results, while an amber capsule follows a longer route and stops at a closed barrier.

Two requests succeed. Both return valid data. The screen still shows the wrong result.

Consider a search field. Someone types re, then finishes typing react. The second request returns first, so the interface shows results for react. A moment later, the first request finishes and replaces them with results for re.

The network panel is green. The input says react. The results answer a question the user has already moved past.

This is a hypothetical example, but it exposes a useful frontend boundary: a response can be valid without still being allowed to change the screen.

Give each request an owner

I would start by writing one rule:

Only the request that represents the current intent may update this view.

For a read-only search, that usually means the most recently started search wins. It does not mean the last response to arrive wins.

The sequence is simple:

  1. Request A starts for re.
  2. Request B starts for react and becomes the current owner.
  3. B succeeds. Its results can be shown.
  4. A succeeds later. Its result is no longer relevant to this view.

The request needs an identity that survives every asynchronous step. A monotonically increasing request ID is enough for a small, single-view example. When a new search starts, increment the ID and capture it in that request's closure. Before committing a result, compare the captured ID with the current one.

Comparing only the search string is weaker. In an A → B → A sequence, the first and third searches have the same text but belong to different attempts. The same applies when someone refreshes the same query.

Protect all the state the request can change

Guarding setResults is a good start. It is not the whole fix.

An old request can also:

  • Replace the current result with its error message.
  • Clear a loading indicator while a newer request is still running.
  • Reset metadata such as the result count or selected item.

This is why a request's catch and finally blocks deserve the same attention as its success path. A cleanup block still writes state if it calls setLoading(false).

For this example, I would keep the query, request ID, status, result and error in one snapshot rather than let unrelated booleans drift apart. The UI can then render one coherent answer: these results belong to this search attempt, which is in this state.

The accompanying runnable example uses that approach. Its small coordinator starts the next owner synchronously when the input changes, ignores stale completions and invalidates the owner when the search is cleared or the view is disconnected. The React adapter reads the snapshot rather than copying its data into another store.

This is the commit boundary from the companion's latest-search.ts. The surrounding function creates identity and controller for each attempt, and owns() compares its request ID with the current generation:

latest-search.ts
typescript

Success and failure end the loading state atomically. The guarded finally releases this request's controller; it cannot clear a newer request's controller.

Cancellation helps, but ownership is the guarantee

AbortController lets a caller signal that an operation is no longer wanted. With fetch, aborting can stop the request or consumption of the response body. It is worth wiring through. MDN: AbortController.abort

But the application should still decide which request may commit. A custom transport may ignore the signal, a response may already have resolved, or later asynchronous work may continue. Cancellation and a commit guard solve different parts of the problem.

The useful pattern is:

  • Invalidate the old request's ownership.
  • Ask its transport to stop.
  • Start the new request with a new identity.
  • Check that identity before applying success or failure.

Debouncing is also useful, but it changes how often requests start. It does not make overlapping requests finish in order. If requests can overlap, they still need an ownership rule.

React adds a rendering boundary

React's Effect documentation shows an ignore flag in cleanup to prevent older fetch results from updating state. Cleanup runs before the next setup when dependencies change, and when the component is removed. React: fetching data with Effects

There is another question to ask when the query comes from props, the URL or a deferred value: what can render before the new Effect has run?

Keep the identity alongside stored data. If a snapshot belongs to the previous query, do not label it as the result for the new one. Either hide it while the current request starts or explicitly present it as a previous result. An Effect cleanup can prevent a later stale write; it does not retroactively make previously stored data belong to the new query.

The same distinction matters with debounced inputs. If the input already says react but the settled request still represents re, the UI needs to acknowledge that gap. “Searching…” or “Previous results” can be more honest than quietly relabeling the old list.

Let the query library manage its own cache

In an application already using TanStack Query, I would normally put this responsibility in the existing query layer rather than add a parallel coordinator.

Include every variable that changes the returned data in the query key. For a search within a workspace, that might be the workspace ID and search text. TanStack Query treats those keys as cache identity. Its query function also receives an abort signal that can be passed to fetch. Query keys, query cancellation

Then render from the query result for the active key. Copying every response into an unkeyed global results array can recreate the race outside the library.

A cache key is not a per-attempt request ID. Returning to an earlier key can intentionally reuse cached data. Decide whether that is the experience you want rather than treating a cache as an automatic promise of fresh results.

Previous-data or placeholder behaviour needs deliberate UI treatment too. Keeping earlier results visible can be useful; presenting them as the new query's answer is misleading. Cache identity is also not an authorization boundary. Account changes and access revocation need their own handling.

Make the bad order a test input

Random delays are a useful demo. They are a poor foundation for a reliable regression test.

Use deferred promises so the test chooses exactly when each request settles. Start A, start B, resolve B, then resolve A. Assert that B is still the visible result. The test should not need to wait for a lucky race.

Then cover the less obvious paths:

  • A rejects while B is still pending. B must remain pending, without A's error.
  • A finishes while B is pending. A must not clear B's loading state.
  • The user clears the field. A late result must not refill it.
  • The sequence is A → B → A. The first A must not win just because its text matches.
  • The view disconnects. Pending work must not publish another snapshot.

The included test suite deliberately uses a fake transport that ignores cancellation. That isolates the ownership guarantee instead of accidentally proving only that the mock happened to abort.

Keep this rule scoped to reads

“Latest intent wins” works well for a replaceable search result. It is not a general policy for every request.

If a user adds two items to a cart, both operations may matter. If an autosave has already reached the server, ignoring its response does not undo the write. Those flows need an explicit server-side ordering or concurrency policy, and possibly idempotency or version checks.

For a read-only view, the review question is smaller and very practical:

If this request finishes after the user has moved on, what is it still allowed to change?

Answer that for the data, the error and the loading state. Then test the order you hope will never happen.

Keep reading

Related articles