01 / The idea
Showing each answer as it arrives is a fair start.
You’re building a product page. Picking a color asks the server whether it’s in stock, shows a spinner, then shows the answer. With one request out at a time, whatever comes back belongs to the color on screen.
Read the first panelTypeScript · the version this lesson starts from
// The first version: show whatever answer arrives. Fine while only one request is ever pending.
export class EveryAnswerPanel {
private view = emptyView();
select(variant: string): void {
this.view = { variant, stock: '', error: '', loading: true };
}
receive(outcome: Outcome): void {
this.view = {
...this.view,
stock: outcome.ok ? outcome.stock : '',
error: outcome.ok ? '' : outcome.error,
loading: false
};
}
clear(): void {
this.view = emptyView();
}
snapshot(): View {
return { ...this.view };
}
} Go’s version is the same panel. Both languages meet again at Latest in section
02.
Then someone picks Blue and, before it answers, changes their mind to Green. Green answers first: sold out. Blue answers a moment later: in stock. The panel now shows In stock under Green, and Add to bag is enabled. Both requests succeeded. The screen is wrong.
A race condition is a bug that depends on the order things finish. In a UI, the usual one is an answer arriving after the user has moved on. The fix is to give each request an identity and let only the latest one update the screen. React’s documentation describes the same case: if the user changes “from 'Alice' to 'Bob'”, cleanup “ensures that the 'Alice' response is ignored even if it arrives after 'Bob'.”
await doesn’t prevent it. It orders the steps inside one request, not separate requests.
Section 05 writes React’s cleanup flag and Svelte’s abort signal, then builds the picker for real.
02 / See the shape
Number each attempt, and let only the latest one write.
The basic form is the numbering: each pick starts a new attempt, and only the latest owns the panel. In the wild puts one check around the whole outcome and lets closing the picker revoke ownership. At the call site runs the out-of-order answers through both panels.
Both languages run the same panels on the same 9 shared schedules, with no network or timers.
Request numbers. Each pick starts a new attempt, and only the latest attempt owns the panel.
// Each selection is a new attempt with its own number. Only the latest attempt owns the panel.
export class Latest {
private next = 0;
private current = 0; // 0 means nobody owns the panel.
start(): number {
this.current = ++this.next;
return this.current;
}
owns(id: number): boolean {
return id !== 0 && id === this.current;
}
revoke(): void {
this.current = 0; // Keep next: a pending request must never share a future number.
}
} // Each selection is a new attempt with its own number. Only the latest attempt owns the panel.
type Latest struct {
next, current uint64 // current 0 means nobody owns the panel.
}
func (l *Latest) Start() uint64 {
l.next++
l.current = l.next
return l.current
}
func (l *Latest) Owns(id uint64) bool { return id != 0 && id == l.current }
// Revoke keeps next: a pending request must never share a future number.
func (l *Latest) Revoke() { l.current = 0 } Reading the TypeScriptA counter, a check, and no await between
Latest.start() hands out the next number and makes it current. receive checks owns(id) and writes the whole view in the same synchronous
call, so nothing can start in between.
owns refuses 0, which is what revoke sets, so a closed picker ignores
every old answer. The counter itself never resets, so an old request can’t share a new request’s
number.
Reading the GoSerialized, not goroutine-safe
The Go panel expects one caller, like a UI event loop. If answers came from goroutines, they’d go through a channel or a mutex first.
Go’s race detector finds unsynchronized memory access. It won’t find this bug, where every access is serialized and the order is still wrong.
03 / Follow the answers
Watch two panels receive the same answers.
Five steps, every screen from running both panels you just read on the same schedule. Each lane runs from a pick to its answer; a struck-through answer is one the latest-only panel ignored. Before each step, guess what the left panel shows.
In Try it, pick colors and answer them in any order you like.
Two panels, the same answers, in the same order.
One pick, one answer
Step 1 of 2: Pick Blue (#1)
Every answer writes
Checking stock…
No answer shown
Add to bagLatest request only
Checking stock…
No answer shown
Add to bagOne pick, one answer. Pick Blue (#1), #1 Blue answers. Every answer writes: Blue, In stock, from #1 Blue, Add to bag enabled. Latest request only: Blue, In stock, from #1 Blue, Add to bag enabled. With one request out, every answer belongs to the color on screen. Both panels agree.
One pick, one answer.
Pick Blue, and Blue’s answer arrives. With one request out, showing every answer is fine.
Reduced motion: choose a scene to see its completed state.
Read this scene
Pick Blue, and Blue’s answer arrives. With one request out, showing every answer is fine.
One pick, one answer. Pick Blue (#1), #1 Blue answers. Every answer writes: Blue, In stock, from #1 Blue, Add to bag enabled. Latest request only: Blue, In stock, from #1 Blue, Add to bag enabled. With one request out, every answer belongs to the color on screen. Both panels agree.
Watch restarts when you return. Step through keeps your selected step. Try it starts with no picks each time you open it.
What request identity buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- The screen matches the choice
- The answer on screen is always for the color on screen. Green shows Sold out, not Blue’s In stock.
- Errors and spinners too
- One check covers the stock, the error, and the spinner, so an old timeout can’t stop Green’s spinner early.
- Attempts, not values
- Picking Blue twice makes two attempts. The first Blue answer is ignored while the second is still checking.
- Closing is a new intent
- Closing the picker revokes ownership, so a late answer can’t fill an empty panel or enable Add to bag.
- Order-proof without locks
- The panel doesn’t need answers in order or a lock around them. It only needs to know which answer it’s waiting for.
The review words are race condition, stale response, request identity, and latest request wins, the rule the right-hand panel follows instead of whichever response happens to land last. Section 08 covers what they cost.
04 / Try a decision
A check that only covers success.
Someone adds request numbers to the first panel, but only around the line that shows the
stock. The error is still set in catch, and the spinner still turns off in finally. The change is in finally.ts, and the lesson’s tests pin
what it shows.
05 / Give it a real job
Reads can be replaced. Writes can’t.
On the real product page, the stock check, the delivery estimate, and the price for a size all follow the latest pick. Add to bag is different: every tap is a request the user meant, and none of them should be ignored.
Latest request wins
An answer for an old pick is thrown away.
Every request counts
Two taps are two requests. Deduplicating them is the server’s job.
Revoke and abort
Nothing still out may update a page that’s gone.
Ignoring an old answer only protects what the page shows. It can’t undo work the server already did, which is why writes need their own rule, like the keys in Idempotency & at-least-once delivery.
The example leaves out the network, retries, and caching. None of those change who owns the panel.
Build UIs?Every data-fetching effect you write has this race, and one day a picker makes you handle stock, errors, and a real Add to bag together.
Where it already is in your components
React’s documentation fetches inside an Effect with a local ignore flag.
Cleanup sets it when the dependency changes, so the old response is ignored even if it
arrives last. Svelte’s getAbortSignal() goes a step further: it returns a signal
that “aborts when the current derived or effect re-runs or is destroyed”, so the old fetch is
canceled as well.
Data libraries have their own version. TanStack Query stores each answer under its query key, and by default doesn’t cancel a request that’s no longer needed: “after the promise has resolved, the resulting data will be available in the cache.” Blue’s late answer goes to Blue’s key, not to the Green you’re looking at.
When you have to own it
Now it’s the picker itself, with a button that puts something in a bag. Each pick calls owner.start(), which aborts the previous request and returns an owns() check. The whole outcome, stock or error or spinner, goes through that one
check.
Aborting saves work, but the check is what keeps the screen right: a response that finished just before the abort still reaches your code. Add to bag reads the color from the panel, the one its stock answer belongs to, and isn’t guarded at all, because a second tap is a second request the user made.
// One owner per panel: starting a request takes ownership and cancels the one before it.
export type Attempt = { signal: AbortSignal; owns: () => boolean };
export function createOwner() {
let current: AbortController | null = null;
return {
start(): Attempt {
current?.abort();
const controller = new AbortController();
current = controller;
// The controller itself is the attempt's identity; no counter to keep in step.
return { signal: controller.signal, owns: () => current === controller };
},
// Closing the panel or leaving the page: nobody owns it any more.
revoke(): void {
current?.abort();
current = null;
}
};
}
A stock badge that ignores answers for a variant that’s no longer selected: React’s documented cleanup flag, and Svelte’s getAbortSignal, which cancels the old fetch too.
import { useEffect, useState } from 'react';
export function StockBadge({ variantId }: { variantId: string }) {
const [stock, setStock] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
setStock(null);
fetch(`/api/stock/${variantId}`)
.then((response) => response.json() as Promise<{ label: string }>)
.then(
(data) => {
// Cleanup ran: another variant is selected now, so this answer mustn't show.
if (!ignore) setStock(data.label);
},
() => {
if (!ignore) setStock('Couldn’t check stock');
}
);
return () => {
ignore = true;
};
}, [variantId]);
return <p aria-live="polite">{stock ?? 'Checking stock…'}</p>;
}
06 / Recognize it elsewhere
Anywhere a new request can start before the last one answers.
You’ve met all of these. For each one, decide whose answer should win.
| Where you’ve seen it | What overlaps | Whose answer should show |
|---|---|---|
| Switching conversations | Two message lists loading | The conversation on screen now. |
| A search box | A request per keystroke | The latest query, not the slowest one. |
| A map you drag | Nearby places for each position | The area in view. |
| “Is this username free?” | A check per letter typed | The value in the field now. |
| Autosave | Two saves of the same draft | Not the page’s call. The server needs a version check. |
Debouncing sends fewer requests, but the ones it sends can still finish out of order. It makes the race rarer; it doesn’t remove it.
07 / Already in your toolbox
Your frameworks already handle this, if you let them.
Three places to look. For each one, find what makes an old answer lose.
React · Synchronizing with Effects
The fetching example with let ignore = false, and why it’s there. The same
page recommends a framework’s data fetching or a client-side cache over fetching in
Effects by hand.
Svelte · getAbortSignal
A signal that aborts when the effect or derived that called it runs again or is destroyed. It has to be called while that effect or derived is running.
Read the reference ↗TanStack Query · Query cancellation
By default, a query that becomes unused before it resolves isn’t canceled. Pass the signal it gives your query function to fetch, and it is.
A useful counterexample: an upload listWhen every result matters
Five photos uploading at once aren’t competing for one panel. Each finishes on its own, and each result belongs to its own row. One “latest wins” check here would throw away four real uploads.
Give each row its own owner, or no owner at all.
08 / The parts to watch
The check has to cover everything an old answer can touch.
These are the places it still goes wrong.
Guard the whole outcome
Checking only the success path leaves the error and the spinner unguarded, which is section 04’s bug. One outcome, one check.
Compare attempts, not values
Checking variant === selected accepts the first Blue answer after Blue, Green,
Blue. Numbers or controllers tell attempts apart; values don’t.
Aborting isn’t enough on its own
An aborted fetch rejects, and that rejection still reaches your error handler. Treat it like any other error and the spinner stops for the wrong request. A response that already finished can’t be aborted either. Keep the ownership check.
Ignoring doesn’t undo writes
The check stops an old answer changing the screen. If that request changed data on the server, the change already happened.
One owner per panel
Two independent panels need two owners. A single shared counter would let a stock check cancel the delivery estimate beside it.
Debouncing isn’t a fix
It sends fewer requests. The ones it sends can still finish out of order.
09 / Make the call
What would you have to change tomorrow?
Give both panels a plausible change and follow the work it creates.
| The change | Show every answer | Latest request only |
|---|---|---|
| The picker waits for each answer before the next pick | Correct, and simpler. | Also correct, with a counter nothing needs. |
| Pick Blue, then Green, and Blue answers last | In stock under a sold-out color. | Sold out. |
| Blue times out while Green is checking | An error, and no spinner for Green. | Still checking Green. |
| The picker closes before Blue answers | In stock with no color chosen. | Stays empty. |
| Every tap on Add to bag must count | Correct. | The wrong tool: it would drop taps. |
Reach for request identity whenever a new request can start before the last one answers, and only the newest answer matters. A picker, a tab, a search box.
Keep showing every answer when requests can’t overlap, or when every result matters, like uploads or Add to bag.
The question I’d leave beside the code is: if an older answer arrived right now, should the user see it?
10 / Take the idea with you
Explain the picker without saying “race condition.”
“Every pick gets a number. When an answer arrives, we show it only if its number is the latest pick’s, and closing the picker means no number is current.” In a review, the words are race condition, stale response, request identity, and cancellation.
Before moving on, jot down why Blue’s answer showed under Green, why the finally block stopped Green’s spinner, and one screen in your own code where a new
request can start before the last one answers.
Connections to follow nextRelated lessons
- Closures and captured state explains why React’s
ignoreflag works: each Effect’s cleanup closes over its own copy. - Idempotency & at-least-once delivery is the rule for writes, which ignoring an answer can’t protect.
- Retry, backoff & idempotency sends the same request again. A retry for an old pick should lose too.
- Discriminated unions can turn the panel’s stock, error, and loading fields into one value that’s only ever one of them.