Back to writing

The bugs between requests: keeping async UI consistent

Some of the more confusing frontend bugs I have seen were not caused by a broken component. The component rendered correctly. The API returned successfully. Yet the screen still showed the wrong thing.

They tended to happen in the space between requests: when a user changed a filter twice, navigated away while data was loading, or updated something and saw an older version of it appear again. Handling those moments has become one of the parts of SPA work I try to think about more deliberately.

A UI can work and still show the wrong data

Imagine a search screen. A user searches for “chairs”, then quickly changes the query to “tables”. If the request for chairs takes longer and returns after the request for tables, it can overwrite the newer result. Nothing has failed technically, but the UI no longer reflects the user’s latest intent.

You typeRequestsScreen shows"chairs""tables"chairs request (slow)tables (fast)loading…tables ✓chairs ✗tables returnschairs returns lateand overwrites it
Both requests succeed. Because the older one finishes last, the screen ends on “chairs” even though the user asked for “tables”.

The same kind of problem can appear after a mutation. A user updates an item, the request succeeds, and a separate refetch brings back an older copy of the data. Or a screen keeps showing a loading state for a request that no longer matters, because the user has already moved on.

These cases have reminded me that a successful request is not always the same as a correct UI. The screen also needs to know whether a response still belongs to the user’s current action.

Give the states clear names

It has helped me to name the state before deciding how to handle it. An initial load is different from a background refresh. Data can be stale without being unusable. A mutation can be pending, successful or failed. A request can be cancelled because the context changed.

StateWhat the UI might do
Initial loadNothing to show yet, so a placeholder or skeleton.
Background refreshKeep the current data visible; a quiet indicator at most.
StaleStill usable; refresh when it makes sense.
Mutation pendingShow the action was received; prevent double submits.
Mutation failedExplain what happened; offer a retry or roll back.
CancelledOften nothing at all, because the user has moved on.

The application does not need a separate UI for every label. But the distinction helps me avoid mixing them all under a single loading flag. A background refetch may let the current data stay visible. A failed save may need a retry or a rollback. A cancelled search request may need no message at all.

Once those differences are visible, the UI has a better chance of responding in a way that makes sense to the person using it.

A few patterns that have helped

For searches and filters, I try to keep the current query or filter state close to the request that uses it. When a response arrives, the UI should apply it only if it still matches the current context. Cancelling an in-flight request with an AbortController when the query changes can also reduce unnecessary work, and ignoring an outdated response still helps when cancellation is not possible. The React docs show the same “ignore” idea in You Might Not Need an Effect.

let controller;

async function search(query) {
  controller?.abort();                 // cancel the request that no longer matters
  controller = new AbortController();
  const current = controller;

  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: current.signal,
  });
  const data = await res.json();

  if (current !== controller) return; // a newer search started: ignore this one
  render(data);
}

After a mutation, the trade-off is often between waiting for the server and updating the UI immediately. Optimistic updates can make an interaction feel quicker, but they need a clear rollback path when the request fails. For changes that are harder to reverse, waiting for confirmation may be the safer choice.

It also helps when server data has one clear owner. Whether that is a query library, a store or a small module around an API, having a consistent place to handle caching, invalidation and refetching makes these decisions easier to trace than spreading them across many unrelated components.

Correctness is part of the experience

I do not expect every screen to need the same level of async handling. A small settings form and a live search have different risks. Still, I have found it useful to ask a few simple questions:

  • What happens if the user acts again before the first request finishes?
  • What data should stay visible while a refresh runs?
  • What should the UI do if a change fails?

Those questions do not remove every edge case, but they have helped me notice the ones that matter before users do. In a SPA, a UI that responds quickly but shows an older or unrelated result can still feel unreliable. Keeping async state consistent is one quiet way to make the product feel more dependable.

Working on something similar? Let's talk