Race Conditions in JavaScript: When Single-Threaded Code Is Not Safe
JavaScript runs one thing at a time, yet async code still races. Lost updates, stale responses and double submits, and the patterns that prevent each one.
- 10 min lezen
Deze pagina is nog niet beschikbaar in het Nederlands, dus je leest het Engelse origineel.
“JavaScript is single-threaded, so we don’t have race conditions” is half true. No two lines of your JavaScript ever run at the same instant, but an async function pauses at every await, and while it is paused, other code runs and can change the state it was about to use. The bug classes this causes are common and costly: lost updates, stale search results, double charges. This post shows exactly where the interleaving happens and the patterns that prevent each race. Every example was run to confirm its output.
Why It Matters
Race conditions are the bugs that pass every test and appear in production under real latency: when two requests overlap, when a user double-clicks, when a slow response arrives after a fast one. They are hard to reproduce because they depend on timing, so the cheapest fix is to recognize the pattern while writing the code.
Where JavaScript Interleaves
Synchronous code runs to completion, so nothing can interrupt the middle of a function that doesn’t pause. Interleaving happens only at yield points: await, callbacks, timers, events and messages. (The event loop post explains why.) The dangerous shape is check, await, act:
let balance = 100;
async function withdraw(amount) {
if (balance >= amount) {
// check
await saveToServer(); // other code runs here
balance -= amount; // act, based on a stale check
return true;
}
return false;
}
await Promise.all([withdraw(80), withdraw(80)]);
// both return true, and balance is -60The check was true when it ran; by the time the code acted on it, the world had changed. This is a time-of-check to time-of-use (TOCTOU) bug, the same class that exists in multithreaded languages.
Real-World Cases
The same check-then-act shape shows up far beyond a browser tab:
The Common Races
| Race | What happens | Typical place |
|---|---|---|
| Lost update | Two operations read, modify and write the same state; one overwrites the other | Counters, carts, form autosave |
| Stale response | An older, slower request finishes last and overwrites newer results | Search-as-you-type, filters, tab switches |
| Double submission | The same action runs twice | Payment and order buttons, retries |
| Initialization race | Two callers both see “not loaded yet” and both load | Lazy singletons, token refresh |
| Cross-tab and cross-device | Two browser tabs or devices edit the same record | Settings pages, collaborative editing |
Stale Responses
let shown = '';
async function onInput(query) {
shown = await search(query); // whichever request finishes last wins
}
onInput('re'); // slow: 50 ms
onInput('react'); // fast: 10 ms
// shown ends up as the results for 're', not 'react'The user typed “react”, but the slower request for “re” arrived last and overwrote it. Network order is not request order.
Patterns That Prevent Races
1. Reserve Before You await
Make the check and the update one synchronous step, so nothing can run between them: check, change local state immediately (the “reservation”), then await, and roll back if the await fails.
async function withdraw(amount) {
if (balance < amount) return false; // check
balance -= amount; // reserve immediately, no await in between
try {
await saveToServer();
return true;
} catch (error) {
balance += amount; // roll back
throw error;
}
}
await Promise.all([withdraw(80), withdraw(80)]);
// true, false, and balance is 20The second call now sees a balance of 20 and stops. Nothing can interleave between the if and the subtraction, because there is no await between them.
Is This an Optimistic Update?
It looks like one, because local state changes before the server confirms. But the goal is different, and the names are easy to mix up:
| Pattern | What it does | What it solves |
|---|---|---|
| Reserve before you await (this one) | Checks and updates local state in one synchronous step, then awaits; rolls back on failure | Two local operations passing the same check |
| Optimistic UI update | Shows the expected result before the server confirms, and reverts it on failure | The UI feeling slow while waiting |
| Optimistic concurrency control | The server accepts a write only if the record still has the version you read | Races between tabs, devices and users |
An optimistic UI update that changes state after an await is not safe on its own. A reservation can make the UI feel fast as a side effect, but its job is to close the gap between check and act.
More Examples of the Same Pattern
Booking a seat or the last item in stock:
const available = new Set(['A1', 'A2']);
async function book(seat) {
if (!available.has(seat)) return false; // check
available.delete(seat); // reserve: a second click now sees it as taken
try {
await api.book(seat);
return true;
} catch (error) {
available.add(seat); // give the seat back
throw error;
}
}Stopping a double submit. The “in progress” flag is the reservation, and it is set before the await:
let submitting = false;
async function placeOrder(order) {
if (submitting) return; // check
submitting = true; // reserve
try {
await api.placeOrder(order);
} finally {
submitting = false; // release, whether it worked or not
}
}Setting the flag after await api.placeOrder(…) would let a fast double-click through, because both clicks would pass the check before either set the flag.
2. Serialize Operations With a Mutex
Sometimes the check itself needs an await: reading the latest balance from the server, or a file from disk. Then you can’t keep check and update synchronous, and you need a mutex.
- A mutex (short for mutual exclusion) is a lock that only one operation can hold at a time. Everyone else waits in a queue until the holder releases it.
- The code that must not interleave, from the check to the update, is the critical section.
- In multithreaded languages a mutex stops two threads from running the critical section at once. In JavaScript there is only one thread, so it stops two async operations from interleaving inside it.
A promise chain makes a tiny mutex:
class Mutex {
#last = Promise.resolve();
run(task) {
const result = this.#last.then(task); // start after the previous task settles
this.#last = result.catch(() => {}); // keep the queue going after failures
return result;
}
}
const lock = new Mutex();
// Using the original, unsafe withdraw from the first example:
await Promise.all([lock.run(() => withdraw(80)), lock.run(() => withdraw(80))]);
// true, false, and balance is 20How it works:
#lastis the promise of the most recent task in the queue.- Each
runchains its task onto it, so the task starts only after the previous one settles, then becomes the new end of the queue. - The
catchmatters: without it, one failed task would reject#last, and every later task would be skipped. - It doesn’t block the thread; other code keeps running while tasks wait.
- Use one lock per resource (per account, cart or file), not one global lock, or unrelated work queues behind each other.
In the browser, the Web Locks API does the same across tabs of the same site:
await navigator.locks.request(`cart:${cartId}`, async () => {
const cart = await loadCart(cartId);
await saveCart(cartId, addItem(cart, item));
}); // the lock is released when the callback’s promise settles3. Put a Timeout on Every Wait
A queue has a new failure mode: if one task hangs, for example a request to a server that never answers, everything behind it waits forever. Every wait needs an upper limit.
Time out the work itself. AbortSignal.timeout(ms) creates a signal that aborts after the given time, and fetch stops the request when it fires:
try {
const response = await fetch('/api/balance', { signal: AbortSignal.timeout(5000) });
render(await response.json());
} catch (error) {
if (error.name === 'TimeoutError') showRetryMessage();
else throw error;
}To stop on either a timeout or a user action (closing a dialog, typing again), combine signals with AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]).
Time out the wait for the lock. Give run an optional signal. If the signal fires while the task is still waiting in the queue, the caller gets an error straight away and the task is skipped when its turn comes:
class Mutex {
#last = Promise.resolve();
run(task, { signal } = {}) {
const turn = this.#last.then(() => {
signal?.throwIfAborted(); // gave up while waiting: skip the task
return task();
});
this.#last = turn.catch(() => {});
if (!signal) return turn;
// Reject as soon as the signal fires, even if the turn hasn’t come yet.
return new Promise((resolve, reject) => {
if (signal.aborted) reject(signal.reason);
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
turn.then(resolve, reject);
});
}
}
const lock = new Mutex();
lock.run(() => slowSync()); // takes 10 seconds
await lock.run(() => withdraw(80), { signal: AbortSignal.timeout(2000) });
// TimeoutError after 2 seconds; withdraw never runsWeb Locks accept the same option: navigator.locks.request(name, { signal: AbortSignal.timeout(2000) }, callback).
For APIs that don’t accept a signal, a small helper turns any promise into one that rejects after a deadline:
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${ms} ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}The finally clears the timer when the work wins, so it doesn’t keep a process alive or fire later.
4. Latest Wins: Ignore or Abort Stale Work
For requests where only the newest result matters, either ignore older responses or cancel them:
let controller;
async function onInput(query) {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
render(await response.json());
} catch (error) {
if (error.name !== 'AbortError') throw error;
}
}Data libraries such as TanStack Query and SWR handle this for you by keying results by request. A simple request counter (“only render if this is still the latest request id”) also works when you can’t abort.
5. Share the In-Flight Promise
For lazy initialization and token refresh, cache the promise, not the result, so concurrent callers wait for the same work:
let tokenPromise;
function getToken() {
tokenPromise ??= refreshToken().finally(() => {
tokenPromise = undefined;
});
return tokenPromise;
}6. Let the Server Enforce the Rules
The client can reduce races but can’t prevent them, because other tabs, devices and retries exist. The server is the place for real guarantees:
- Transactions or conditional updates:
UPDATE accounts SET balance = balance - 80 WHERE id = 1 AND balance >= 80, checking that one row changed. - Optimistic concurrency: send a version or
ETag, and reject the write (HTTP 409 or 412) if the record changed since it was read. - Idempotency keys for actions that must happen once: the client generates a key per user intent, and the server returns the original result for repeats.
Going Deeper: Locks Across Machines
Everything above protects state inside one JavaScript process or one browser. Once several servers share a resource, such as a file in object storage, a row updated by background jobs or a third-party account, teams often reach for a distributed lock (in Redis, a database or a coordination service such as etcd or ZooKeeper). Locks with an expiry have a subtle failure mode:
Fencing Tokens
A process can pause at any moment (a garbage-collection pause, a slow network, a laptop lid closing) and wake up still believing it holds the lock. The fix, described by Martin Kleppmann, is a fencing token:
- Every time the lock is granted, the lock service returns a number that only goes up.
- The client sends it with every write.
- The storage rejects writes with a lower token than one it has already seen.
The lock stays an optimization; the storage enforces correctness.
The same principle runs through this whole post: put the final check where the data lives. A conditional UPDATE … WHERE version = 7, a unique constraint, or an idempotency key stored next to the result protects you from every client, tab, retry and paused process at once.
Test Yourself
Predict the output, then open the answer. Every answer was checked by running the code with Node.js.
Uitdaging 1
Three Increments
let count = 0;
async function increment() {
const current = count;
await null;
count = current + 1;
}
await Promise.all([increment(), increment(), increment()]);
console.log(count);Antwoord tonenAntwoord verbergen
1
- All three calls read
count(0) before any of them resumes after theawait. - Then each writes
0 + 1, so two updates are lost. - This is the read-await-write shape in its smallest form.
Uitdaging 2
The Same Increments, Reordered
let count = 0;
async function increment() {
await null;
count++;
}
await Promise.all([increment(), increment(), increment()]);
console.log(count);Antwoord tonenAntwoord verbergen
3
- The read and the write (
count++) now happen together, synchronously, after theawait. - Nothing can run between them, so no update is lost.
- Moving the
awaitso that check and update are never separated is often the whole fix.
Uitdaging 3
Search as You Type
let latest = 0;
let shown;
async function search(query, ms) {
const id = ++latest;
const result = await new Promise((resolve) => setTimeout(() => resolve(query), ms));
if (id === latest) shown = result;
}
await Promise.all([search('re', 50), search('react', 10)]);
console.log(shown);Antwoord tonenAntwoord verbergen
react
- The slow
rerequest finishes last, but itsid(1) is no longer the latest (2), so its result is ignored. - A request counter is the simplest “latest wins” guard when you can’t abort the request itself.
Uitdaging 4
Three Callers, One Token
let calls = 0;
let pending;
function load() {
pending ??= new Promise((resolve) =>
setTimeout(() => {
calls++;
resolve('token');
}, 10),
);
return pending;
}
await Promise.all([load(), load(), load()]);
console.log(calls);Antwoord tonenAntwoord verbergen
1
- The first call stores the in-flight promise; the next two find it already set and share it.
- Caching the promise rather than the result makes concurrent callers wait for one piece of work.
- A real version also clears
pendingon failure, so a later call can retry.
Uitdaging 5
A Cache That Fetches Twice
const cache = new Map();
let fetches = 0;
async function get(key) {
if (!cache.has(key)) {
const value = await new Promise((resolve) =>
setTimeout(() => {
fetches++;
resolve(key.toUpperCase());
}, 10),
);
cache.set(key, value);
}
return cache.get(key);
}
await Promise.all([get('a'), get('a')]);
console.log(fetches);Antwoord tonenAntwoord verbergen
2
- Both calls check the cache before either has stored anything, so both fetch.
- This cache stampede multiplies load exactly when a popular key expires.
- Fix: store the promise in the cache instead (
cache.set(key, fetchValue(key))), as in challenge 4.
Key Takeaways
- JavaScript runs one thing at a time, but every
await, callback or event is a point where other code can change shared state. - The dangerous shape is check, await, act: the check may be stale by the time you act.
- Reserve before you await: keep the check and the update in one synchronous step. That is different from an optimistic UI update.
- When the check itself must await, serialize operations on the same resource with a mutex (a promise queue or Web Locks).
- Put a timeout on every wait, and pass an
AbortSignalinto the work so a timeout actually stops it. - For “latest wins” requests, abort or ignore stale responses; for shared initialization, cache the in-flight promise.
- Enforce real guarantees on the server with conditional updates, version checks and idempotency keys.
Related: how promises and async/await actually work explains where await hands control back, and the hidden cost of mutable state covers the shared state these races corrupt.
Sources
- Martin Kleppmann: How to do distributed locking (2016)
- AWS: Summary of the Amazon DynamoDB service disruption in the Northern Virginia (US-EAST-1) region (October 2025)
- Bruce Schneier: Race condition exploit in Starbucks gift cards (2015)
- MDN: AbortController and AbortSignal.timeout()
- MDN: Web Locks API and LockManager.request()
- MDN: HTTP conditional requests
- IETF draft: The Idempotency-Key HTTP header field
- TanStack Query: Query cancellation
Tags
Veelgestelde vragen
Can a race condition happen without await or callbacks?
NoNot inside a single piece of synchronous JavaScript, because it runs to completion. Races need an interleaving point, such as await, a callback, a timer, an event, or another process or browser tab sharing the same data.
Is disabling the submit button enough to stop double submissions?
NoIt helps the common case, but it doesn’t cover retries, a second tab or a slow network where the first request succeeded but the response was lost. Use an idempotency key on the server as well.
What should happen when an operation times out?
Treat the result as unknown, not as failed. The server may have completed the work after the client stopped waiting, so retry only with an idempotency key, or check the current state before trying again.
What is a time-of-check to time-of-use (TOCTOU) bug?
A bug where code checks a condition, then acts on it later, and the condition changes in between. In JavaScript the gap is usually an await; between processes it is any delay.
What is a cache stampede?
Many concurrent requests miss the cache for the same key at the same time and all recompute or refetch it. Cache the in-flight promise, or use a lock or a stale-while-revalidate strategy, so only one request does the work.
Are optimistic UI updates safe?
YesFor perceived speed, as long as the server remains the source of truth and the UI rolls back or reconciles when the server rejects the change. They don’t prevent races on their own.
How do I test for race conditions?
Make the timing explicit: use fake timers or controllable promises to force the bad interleaving in a unit test, add artificial latency in development, and test the server with concurrent requests for the same resource.
What is a fencing token?
A number that increases every time a lock is granted. Clients send it with each write, and the storage rejects writes with an older token, which protects data from a client that kept working after its lock expired.