Race Conditions in UI State

TL;DR / Race conditions in UI state occur when multiple async operations complete out of order, causing the UI to display stale data from an earlier request instead of the most recent one.

How It Works

 ┌──────────┐          ┌──────────┐         slow
 │ Click A  │─────────→│ fetch(A) │─┌───────────────────┐
 └──────────┘          └──────────┘                     ↓
                                                 ┌────────────┐
                                                 │   State    │
                                         ┌──────→│  shows A!  │
                                       fast      │            │
                                         │       └────────────┘
 ┌──────────┐          ┌──────────┐      │
 │ Click B  │─────────→│ fetch(B) │──────┘
 └──────────┘          └──────────┘

Edit diagram

Race conditions are among the most common bugs in frontend applications, yet they frequently go undetected during development because they depend on network timing. The classic scenario: a user types in a search box, triggering a fetch for "Re" and then "React." If the "React" response arrives first (50ms) and "Re" arrives second (200ms), the UI shows results for "Re" -- the outdated query. The user sees the wrong results with no indication that anything went wrong.

Why This Happens

JavaScript is single-threaded but non-blocking. When you fire an async operation, you register a callback (or .then/await handler) that runs whenever the response arrives. Multiple in-flight requests have no inherent ordering guarantee. Network latency varies per request depending on server load, payload size, caching, and routing. The assumption that requests resolve in the order they were initiated is the root cause of every UI race condition.

Common Manifestations

Search-as-you-type: Every keystroke fires a new request. Slow responses for early keystrokes overwrite fast responses for later keystrokes. The user types "React hooks," but sees results for "React" because that response arrived last.

Tab/filter switching: User clicks Tab A, then quickly clicks Tab B. Tab A's data loads slowly, Tab B's data loads fast. Tab B renders correctly, then Tab A's response arrives and overwrites Tab B's content even though the user is on Tab B.

Pagination: User clicks page 2, then page 3 before page 2 loads. Page 3 data arrives first and renders. Page 2 data arrives second and replaces page 3's content. The URL says page 3, but the data is from page 2.

Optimistic updates with concurrent edits: User edits item A, then item B. Item B's save completes first and returns the server state. Item A's save completes second, returning a different server state that overwrites item B's changes in the UI.

Solutions

AbortController is the most direct fix. Create an AbortController for each request, and abort the previous one when a new request starts. Aborted fetches reject with an AbortError, which you catch and ignore. This ensures only the latest request's response is processed, and it also cancels the actual HTTP request, saving bandwidth and server resources.

Request ID tracking maintains a counter or unique ID. When initiating a request, store the current ID. When the response arrives, check if the stored ID still matches. If it has changed (because a newer request was initiated), discard the response. This is simpler than AbortController but does not cancel the HTTP request.

React's built-in mechanisms: The useEffect cleanup function provides a natural cancellation point. Set a cancelled flag in the cleanup that runs before the next effect. In the response handler, check the flag and bail if set. React 18's useDeferredValue and useTransition handle some race conditions by deprioritizing stale renders, but they do not cancel network requests.

State machines eliminate race conditions by making the system's state explicit. A fetch state machine with states idle -> loading -> success | error can reject responses that arrive when the machine has transitioned to a new loading state. The machine enforces that only the response matching the current loading context is accepted.

Debouncing reduces the frequency of requests (e.g., waiting 300ms after the last keystroke before fetching) but does not eliminate race conditions. Two debounced requests can still resolve out of order. Debouncing is a performance optimization, not a correctness solution.

Gotchas

  • useEffect without cleanup creates races by default. Every effect that fetches data needs either an AbortController or a stale-closure flag in its cleanup function.
  • Debouncing does not prevent race conditions -- it reduces their likelihood but two debounced requests can still resolve out of order. Always combine debouncing with cancellation.
  • async/await does not serialize requests. Two await fetch() calls in separate event handlers run concurrently. The await only pauses the individual handler, not the event loop.
  • React's StrictMode double-invocation in development can reveal race conditions by mounting, unmounting, and remounting components, exposing missing cleanup logic.
  • Stale closures in React cause a related bug: the .then callback captures the state from when the effect ran, not the current state. This can cause incorrect state updates even when responses arrive in order.