Blog #5: 200 OK – The system's sweet little lies
The story of times when the Network Tab was perfectly green but the UI showed something else entirely, and the lesson of managing asynchronous state in the real world.
There was a naive belief I held for many years before finally letting go: believing that as long as the Network Tab shows a green 200 OK, everything is fine.
I remember once implementing a "Search-as-you-type" feature for a large-scale human resources management project. When I typed "Nam", the correct results appeared. But when I quickly typed "Nguyen Nam", something strange happened: the interface displayed a list of people named "Nam" (the result of the first keyword), even though the search bar clearly said "Nguyen Nam".
Checked the Network? Everything was perfect. Both requests returned 200 OK with accurate data. So why did the UI dare to "betray" the data like that?
1. The Silent Killer: Race Condition
In Frontend development, we live in an uncertain world. When you send off 2 requests in order A then B, there is no physical law guaranteeing B will return after A.
If Request A hits a bottleneck at the database and takes 2 seconds to respond, while Request B runs through the cache and returns in just 0.5 seconds, then the result of A (which is now old) will overwrite the result of B. This is the Race Condition—one of the most annoying and hard-to-reproduce errors.
2. The Problem: When data "steps" on each other
My system at the time handled extremely complex search filters. Users could select department, position, and type a name at the same time. Every time something changed, a new request was sent off.
// My naive logic at the time
const [searchResult, setSearchResult] = useState([]);
const onSearch = async (query) => {
const data = await fetchSearchAPI(query);
setSearchResult(data); // The "trap" is here
};
Suppose a user types "A" (Request 1), then deletes it and types "B" (Request 2). If Request 1 is slower, after Request 2 has already finished displaying results for "B", Request 1 finishes and calls setSearchResult with data for "A". The user sees "B" in the search bar but the results are for "A". A perfect mismatch.
3. The Process: Searching for the missing key
Initially, I thought the error was logic at the Backend. I forced the Backend team to log the request and response times. Result: the Backend was completely innocent. They handled everything in the right order with the right data.
I started to "question life" and thought about using a loading flag. But loading only helps show a spinner—it doesn't solve old data overwriting new data.
The lifesaver for me then was AbortController. I realized I needed a way to "terminate" old requests as soon as a new request was initialized.
The final fix:
const abortControllerRef = useRef(null);
const onSearch = async (query) => {
// If a request is already running, cancel it!
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const data = await fetchSearchAPI(query, {
signal: abortControllerRef.current.signal
});
setSearchResult(data);
} catch (error) {
if (error.name === 'AbortError') return; // Ignore if we intentionally canceled it
// Handle other real errors...
}
};
4. Trade-offs: Consistency vs. Performance
Using AbortController helps the UI always remain consistent with the user's final input. However, it also has a downside: you're wasting server resources if users type/delete continuously. The server still has to process that request until it detects the connection has been closed.
If I were to do it again, I would combine Debounce (wait for the user to stop typing for 300ms before sending a request) with AbortController. This combination brings a perfect balance between user experience and system performance.
5. Junior vs. Senior: Conditional Trust
A Junior developer often believes: "If the set function is called last in the code, it will update the final value." They forget the Time factor.
A Senior developer approaches with "State Invalidation". They always ask: "If this data comes back slower than the data after it, what will happen?". They don't just manage data—they manage the lifecycle of that data.
Nowadays, with tools like React Query or SWR, this issue has been resolved "under the hood." But understanding the nature of Race Conditions will help you handle cases where libraries aren't used, such as handling WebSockets or other complex side effects.
6. Lessons Learned
After that "Out of Sync Search" incident, I learned two major lessons:
- Asynchronous data is untrustworthy: never assume the return order of API calls.
- Cleanliness in UI starts from cleanliness in Requests: always clean up old effects or requests before starting new ones.
If you find your UI is "mismatched," don't rush to check the API body. Check if you're living in a race where you're the one losing.
In the Async world, the one who finishes first isn't necessarily the winner.
Series • Part 5 of 50