Topic 4.7
Races and Double Submits
In one line
A race is two things happening at once whose order you don't control: two sends in a row, a stream finishing after the user moved on. The fixes are a guard that answers instantly (a ref), a request id or abort signal so stale pieces are ignored, and cancelling the old request.
Think of it like this
Two customers shout orders at one waiter at the same moment. If the waiter writes both on the same slip, you get one dish made of two orders. The fix is a rule: one order at a time, each on its own numbered slip, and if a customer changes their mind the old slip is torn up. Chat code needs the same rule: numbered requests, and only the newest may write to the screen.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Race condition
- A bug that depends on which of two things happens first.
- Double submit
- The same action sent twice by a quick double click or double Enter.
- Guard
- A check at the start of a function that returns early when the action isn't allowed.
- Ref (useRef)
- A box whose
.currentchanges immediately and doesn't cause a render. - In flight
- A request that has been sent and hasn't finished yet.
- Stale
- Belonging to an older request than the current one, so its results are no longer wanted.
- Request id
- A number given to each request so late results can be recognised and ignored.
- AbortError
- The error a cancelled fetch raises. It means "cancelled on purpose", not "failed".
- Idempotency key
- A unique id sent with a request so the server can do it once even if it arrives twice.
Step by step
01Why a state guard is not enough
The guard below looks right and is not. It reads busy from the render the click came from, and setBusy(true) happens only after the await. A second click in that gap passes the check.
async function send() {
if (busy) return; // busy is state: still false for the second click
await prepare(); // anything async: validate, trim history, fetch a token
setBusy(true); // too late
await fetch(...);
}02A ref changes at once
The second live example renders both versions and sends a double click (two clicks 5 ms apart) to each. The server counts what really arrives: 2 requests from the state guard, 1 from the ref guard.
const inFlight = useRef(false);
async function send() {
if (inFlight.current) return; // changes the instant it is set
inFlight.current = true; // first line, before any await
setBusy(true); // only for the screen
try { await prepare(); await fetch(...); }
finally { inFlight.current = false; setBusy(false); }
}03Two streams, one bubble
The first live example holds two streams open by hand and feeds them pieces in a chosen order, so the interleaving is the same on every run. Question A starts, then B starts before A finishes. The naive client mixes them: "Your order The return window shipped. is 7 days.".
The fixed client aborts A when B starts and also checks the request number before writing. Its screen shows only "The return window is 7 days.". The two lines starting with a bracket are the server noticing that its stream for A was closed when the client aborted.
04Cancelled is not failed
When you abort, fetch and the stream reader reject with an error whose name is AbortError. Catch it and do nothing. If the catch treats it as a failure, every new question flashes "Mitra could not answer" for the one before it.
Try it yourself
- 1
Remove the request number only
In the first example's second half, delete the line
if (mine !== latest) return;and keep the abort.The output is still correct here, because the abort already stopped A. The number check matters when a piece was already read before the abort took effect. Keep both: the abort saves money, the number guarantees correctness.
- 2
Click 80 ms apart
Change the 5 in the second click's
setTimeoutto80and run the second example.Both buttons now report 2. By 80 ms the first request has finished and released its lock, so the second click is a legitimate new send. A guard stops overlapping requests, not requests one after the other. The bug is only visible when clicks are faster than the first handler.
Code & diagrams
The script feeds the pieces in a fixed order, so the mix-up is the same on every run. The two lines in brackets show the server noticing that the client cancelled request A.
Preview
Console
Nothing logged yet.Expected console output
1. naive: "Your order The return window shipped. is 7 days."
(the stream for A is already closed)
(the stream for A is already closed)
2. only the newest writes: "The return window is 7 days."Each button gets a double click. The numbers come from the server's own counter, so they are what would reach your bill. A real button would also be disabled by busy, but the state guard sets busy only after an await, so the second click arrives first.
Preview
Console
Nothing logged yet.Expected console output
requests that reached the server: {"state":2,"ref":1}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Cancel nothing, check nothing
In the first example's second half, delete the line if (current) current.abort(); and the line if (mine !== latest) return;.
Myth vs fact
Myth
Disabling the button is enough.
Fact
A fast double click or a second Enter press can arrive before the button is disabled, for example when the handler awaits something before it sets busy, or when the request is also started from somewhere else (a keyboard shortcut, a retry). Guard in the handler with a ref as well.
Myth
if (busy) return is a reliable guard.
Fact
If busy is state, it is the value from the render the handler was created in. It is only updated by a later render, so a second call that arrives while the first one is still awaiting reads the old value. Use a ref.
Myth
Aborting a fetch is an error to show the user.
Fact
An abort you started yourself is intentional. Check error.name === "AbortError" and ignore it.
Myth
Race bugs show up in tests.
Fact
They need two actions at specific moments. Write tests that click twice quickly and switch mid-stream.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Use a request number even when you also use
AbortController: abort is asynchronous, and one moreread()can resolve before the reader notices. - ▸
In React, abort in the cleanup of the effect that started the request, and in Strict Mode development remember effects run twice: the first request is aborted by cleanup, which is exactly this topic working.
- ▸
For server-side side effects (placing an order from a chat), send a unique idempotency key with the request. The server stores the key with the result and returns the stored result for a repeat.
- ▸
The server should refuse a second concurrent request for the same chat from the same user, or queue it. Client guards help honest users; they don't stop a script.
Remember this
- 1
The problem this solves: Network answers arrive when they arrive, and users click faster than answers finish. Without care you get words from two answers mixed in one bubble, an old answer overwriting a new one, or two charged requests from one double click. These bugs pass every test on a fast connection and appear in production.
- 2
Why they happen. JavaScript runs one piece of code at a time, but
awaitpauses a function and lets other code run. Between the pause and the resume, anything can change: another click, another stream, a chat switch. Code written as if nothing changes while it waits is exactly where races live. - 3
Race 1: two sends. The user double-clicks or presses Enter twice. If the guard is
if (busy) return;andbusyis React state, it can fail: a click handler sees thebusyof the render it was created in, and a state update takes effect only on the next render. When the handler does something asynchronous (anawait) before it callssetBusy(true), a second click arrives in that gap, and both clicks seebusy === false. The live example below proves it with a real count of requests reaching the server. - 4
The fix: a guard that changes immediately. A
useRefvalue changes the moment you assign it, with no waiting for a render. SetinFlight.current = trueas the first line, before anyawait, and reset it infinally. Keep the state flag only to disable the button on screen. The ref is the lock; the state is the picture of the lock. - 5
Race 2: stale pieces. *(Recall from Topic 3.1:
controller.abort()cancels afetch, and the cancelled call rejects with anAbortError.)* A new question is asked while the old stream is still arriving. Both loops write to the same place. Two defences, use both: (1) abort the old request (controller.abort()), which stops the bytes and stops the cost, and treat the resultingAbortErroras normal, not as a failure; (2) give each request a number (const mine = ++latest) and, before every write, checkmine === latest. The abort is not instant, and a piece already read can still be waiting to be written, so the number check is the safety net. The first live example shows both the bug and the fix. - 6
Race 3: a stream that finishes after the user moved on. The user switches chat or deletes it (Topic 4.5), or leaves the page. The request must be tied to its chat id and cancelled when its chat or the component goes away. In React, abort in the effect's cleanup function, and ignore writes after unmount.
- 7
Race 4: out-of-order answers to non-streaming calls. Anything triggered by typing (suggestions, a title request) can return out of order: the answer to the older input arrives last and overwrites the newer one. Same cure: a request number, or abort the previous request first.
- 8
Not every repeat is a bug, and not every guard is enough. The server can see the same request twice anyway (a retry, a flaky connection). For actions with side effects, send an idempotency key so the server can recognise a repeat and do the work once. For plain chat answers, the cost is tokens, so the client-side guard is the main defence, and the server's per-user rate limit is the backstop (Topic 3.6).
- 9
Test races on purpose. Make the fake server slow, click twice 5 ms apart, switch chats mid-stream, delete a chat mid-stream, and assert on the final text. A test that never leaves the happy path will never find these.
Explain it without notes
Why can if (busy) return fail to stop a double submit?
How do you stop an old stream from writing into a newer answer?
Why must an abort not be shown as an error?
What is an idempotency key for?
Practice
Write a helper latestOnly() that returns a function begin(). Calling begin() aborts the previous request's controller and returns { signal, isLatest }, where isLatest() says whether this call is still the newest.
Fix this handler so a double click sends one request: async function send() { if (busy) return; setBusy(true); await fetch(url); setBusy(false); }.
Trade-offs
- ↔
Abort-and-replace gives the user the newest answer and saves money, but throws away a half-finished answer they may have wanted. Queue-and-wait keeps every answer and makes the user wait. Most chats block a second send while one is running, and offer a stop button.
- ↔
Request numbers add a variable to every async function. They are cheap, and they are the only thing that stays correct when abort timing is imperfect.
Done when you can
I can explain why a state-based busy guard can let a double submit through.
I can write a ref guard and release it in
finally.I can abort the previous request and ignore stale pieces with a request number.
I can tell an
AbortErrorfrom a real failure.I can write a test that clicks twice quickly and switches chats mid-stream.