← Concepts & practices
Concept Concurrency, scheduling, and delivery

Race conditions in UI

The latest request owns the screen.

You already click faster than the network answers: switching tabs, picking a size, typing into a search box. Most of the time the answers come back in the order you asked. Let’s follow a product page’s color picker to the moment they don’t, and give each request a way to know whether it still owns the screen.

TypeScriptGo One stock panel, two implementations.

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
stock.ts
// 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.

TypeScriptReading
stock.ts
// 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.
	}
}
GoAlongside
stock.go
// 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.

Race conditions

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 bag

Latest request only

Checking stock…

No answer shown

Add to bag

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.

01/ 05
Pick one color

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.

Blue’s request times out while Green is still checking. What does the panel show?

The success path sets the stock only if latest.owns(id). The error is set in catch, and the spinner turns off in finally.

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.

Stock and price

Latest request wins

An answer for an old pick is thrown away.

Add to bag

Every request counts

Two taps are two requests. Deduplicating them is the server’s job.

Leaving the page

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.

owner.ts
// 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.

ReactAlready in your code
StockBadge.tsx
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.

Familiar screens, what overlaps, and whose answer wins
Where you’ve seen itWhat overlapsWhose answer should show
Switching conversationsTwo message lists loadingThe conversation on screen now.
A search boxA request per keystrokeThe latest query, not the slowest one.
A map you dragNearby places for each positionThe area in view.
“Is this username free?”A check per letter typedThe value in the field now.
AutosaveTwo saves of the same draftNot 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.

Read the section ↗

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.

Read the guide ↗
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.

How a change affects a panel that shows every answer and one that shows only the latest request
The changeShow every answerLatest request only
The picker waits for each answer before the next pickCorrect, and simpler.Also correct, with a counter nothing needs.
Pick Blue, then Green, and Blue answers lastIn stock under a sold-out color.Sold out.
Blue times out while Green is checkingAn error, and no spinner for Green.Still checking Green.
The picker closes before Blue answersIn stock with no color chosen.Stays empty.
Every tap on Add to bag must countCorrect.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

Take the panel into your editor. Add a second panel for delivery estimates with its own owner, and check that picking a color can’t cancel it.

Back to Concepts & practices →