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.
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.
| State | What the UI might do |
|---|---|
| Initial load | Nothing to show yet, so a placeholder or skeleton. |
| Background refresh | Keep the current data visible; a quiet indicator at most. |
| Stale | Still usable; refresh when it makes sense. |
| Mutation pending | Show the action was received; prevent double submits. |
| Mutation failed | Explain what happened; offer a retry or roll back. |
| Cancelled | Often 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