Blog #22: Click Race Condition – When User's click speed beats your App
Analyzing the decision to handle high-frequency interactions in a B2B Dashboard with millions of records and the 'Mismatched Data' incident.
I am managing a team of 4 developers building a Logistic Management Dashboard. The average data scale is about 10,000 waybills per page. The deadline is looming and we've encountered a "bittersweet" problem: Users (those coordinating warehouses) have extremely fast mouse click speeds. They can click and select 5-7 different filters in just 2 seconds.
The Problem: When data "can't catch up" with the UI
The problem appears when requests sent out have different response times. Request #1 (filter by date) takes 1000ms, while Request #2 (filter by warehouse) takes 200ms. The result: Request #2 finishes first, and the UI displays warehouse data. Then Request #1 finishes and overwrites the UI, displaying date data. At this point, the User sees the "Warehouse" filter selected, but the figures are for "Date". A true data disaster!
Options Considered
Our team discussed and came up with two ways to solve it:
Option 1: Use UI Block (The Safe Road)
- Solution: When a user clicks, show a loading Overlay or Disable all buttons until there's a result.
- Pros: Extremely easy to code; never worry about race conditions (as only 1 request is out at a time).
- Cons: Disaster for UX. Users feel a "hitch" with every operation. For a dashboard needing speed, this is unacceptable.
Option 2: Request Cancellation with AbortController (The Techy Road)
- Solution: As soon as there's a new request, we "cancel" (abort) the old request in flight. At the same time, use Optimistic UI so users see the filter has been received.
- Pros: Smooth UX; users can click as much as they want; the app always displays the result of the last click.
- Cons: More complex logic; need to handle
AbortErroreverywhere to avoid false error reports to users.
Final Decision and Analysis
I decided to choose Option 2.
// Example of automatic request cancellation structure
const searchProducts = (params, signal) => {
return fetch(`/api/products?${params}`, { signal });
};
// Inside Component/Hook
useEffect(() => {
const controller = new AbortController();
fetchData(params, controller.signal)
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') return; // Ignore errors from canceled requests
showError(err);
});
return () => controller.abort(); // Cleanup when component unmounts or params change
}, [params]);
Impact on Performance: The Network Tab looks "cleaner" because old requests are canceled (status: Canceled), freeing up bandwidth for the latest request. The CPU no longer has to handle rendering data sets that have been discarded.
Impact on Maintainability: We had to write a custom useFetch hook to encapsulate all the AbortController logic. This transforms a complex logic into a single line of code for future developers to reuse.
Impact on Team: The Juniors were initially unfamiliar with the AbortController concept, but after explaining that it's like "canceling a just-placed order to place a new one," everyone caught on quickly and applied it consistently.
Self-Reflection: Was it Over-engineering?
I wonder: Why not just use debounce for simplicity? In reality, debounce only helps reduce the number of requests sent; it doesn't solve the "first request arriving after the second request" problem. AbortController is the most fundamental solution.
If I went back to that time, would I choose differently? No. Dashboard UX is the number 1 priority. Investing an extra 2 days to handle a sustainable request architecture is a completely worthwhile trade-off compared to letting users get frustrated by "dancing" data every day.
Notes on mastering the rhythm between user and server.
Series • Part 22 of 50