Chapter 5 — Race Conditions in Single-Threaded JS
One thread, many interleavings: how two async calls corrupt shared state, and how to find, prove and fix it
Part 2 of the async core. Prerequisites: Chapter 4 — async/await Traps (every await is a yield point) and Chapter 1 — The Event Loop Under Pressure. Database background: the isolation and locking chapters of PostgreSQL First Principles. Practice: the gym, exercises 01-search-box, 02-credit-wallet and 05-webhook-handler.
How to Read This Chapter
This is the chapter the interview is built around. You will be handed code that passes its tests, and asked "what happens if two of these run at once?" The answer is never "JavaScript is single-threaded, so nothing". This chapter gives you the mechanism, a taxonomy of the eight races you will meet, a method for spotting them in code you did not write, a way to prove them with a test, and the fixes with their limits.
Read Parts 1 and 2 slowly. Everything else is the same idea applied to different state.
| Term | Meaning |
|---|---|
| Race condition | A bug where the outcome depends on the timing of interleaved async steps. |
| Interleaving | One particular order in which the steps of two or more async flows run. |
| Run-to-completion | A JS callback or synchronous stretch of code runs to its end before any other JS on the same thread starts. |
| Critical section | The code between reading shared state and writing a result that depends on what was read. It must not be interleaved with another flow on the same state. |
| Atomic | Indivisible: no other operation can observe or act in the middle of it. |
| Check-then-act | Read state, decide, then act on the decision. Also called TOCTOU (time of check to time of use). |
| Read-modify-write (RMW) | Read a value, compute a new one, write it back. |
| Lost update | Two RMWs on the same value; the second write overwrites the first, and one change disappears. |
| Mutex | A lock that admits one holder at a time. |
| Semaphore | A lock that admits up to N holders at a time. |
| Single-flight | Concurrent callers asking for the same thing share one in-flight promise. |
| Idempotency key | A unique ID for one logical operation, so that repeating it has no extra effect. |
| Optimistic concurrency | Write only if the version you read is still current; otherwise retry or report a conflict. |
Part 1: What a Race Is, Precisely
A race condition is a bug where the outcome depends on the relative timing of interleaved async steps. Each step is correct on its own. Every interleaving is a legal execution. Some interleavings produce a wrong result.
For a race, you need three things:
- Shared mutable state. A variable, a
Map, a cache entry, a React state value, a database row, a Redis key, a file, a Stripe customer record. - At least two flows that touch it. Two requests, two clicks, two webhook deliveries, a timer and a request, two instances of your service.
- A yield point between a read and a write that depends on that read. In JavaScript that means an
await, a callback, or an event. In a database it means the gap between two statements.
Remove any one of the three and the race is gone. Every fix in Part 7 removes one of them: it stops the state being shared, stops the flows overlapping, or removes the gap.
Two things are not races in this sense. A slow operation is not a race. A bug that happens on every run, whatever the timing, is not a race. Races are the bugs that pass every test you wrote, because tests run one call at a time.
Part 2: Why One Thread Still Races
JavaScript has one thread per event loop, and run-to-completion: once a callback starts, no other JavaScript on that thread runs until it returns or reaches an await. So this is safe, even with a thousand concurrent requests:
// Synchronous: nothing else can run between the read and the write.
function chargeSync(wallet, amount) {
if (wallet.balance < amount) throw new Error('insufficient credits');
wallet.balance -= amount;
}
Add one await in the middle, and it is not:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const balances = new Map([['u1', 100]]);
const db = {
async getBalance(id) { await sleep(5); return balances.get(id); },
async setBalance(id, v) { await sleep(5); balances.set(id, v); },
};
async function charge(userId, amount) {
const balance = await db.getBalance(userId);
if (balance < amount) throw new Error('insufficient credits');
await db.setBalance(userId, balance - amount);
return balance - amount;
}
const results = await Promise.allSettled([charge('u1', 80), charge('u1', 80)]);
// [ 'fulfilled', 'fulfilled' ], balance 20: 160 credits spent from a balance of 100
The critical section, from reading balance to writing balance - amount, spans an await. At that await, charge A pauses and the event loop is free to run charge B, which reads the same 100.
Concurrency is not parallelism. Concurrency means tasks take turns, interleaving at yield points. Parallelism means tasks run at the same instant on different cores. Node gives you concurrency on one thread; parallelism needs worker_threads or more processes (Chapter 8). Races need only concurrency.
| Threads (Java, Go) | One JS event loop | |
|---|---|---|
| Where another flow can cut in | Between almost any two machine instructions | Only at await, callbacks and events |
Is x = x + 1 atomic? | No | Yes, if there is no await in it |
| Can you see the yield points? | No | Yes: every one is written in the code |
| Main tool | Locks around memory | Keep critical sections synchronous, or serialise async ones |
That last row is the good news. In JavaScript every place a race can start is marked in the code by await. You can find them by reading.
Now drag the latencies yourself. Try to find the timing where both charges succeed, then turn on the fix.
Part 3: The Taxonomy
Eight shapes cover almost every race you will meet. Learn to name them: "this is a check-then-act across an await" is a better interview answer than "there might be a race here".
| # | Shape | Shared state | Typical symptom |
|---|---|---|---|
| 1 | Stale responses | What is on screen | The UI shows results for an old query |
| 2 | Check-then-act | A balance, a stock count, "does it exist?" | Overspend, duplicate rows |
| 3 | Read-modify-write | A counter, a JSON blob, a list | Increments or edits go missing |
| 4 | Double submit | The operation itself | Two orders, two charges, two jobs |
| 5 | Initialisation | A lazily created client | Two connections, a leaked pool |
| 6 | React effects | Component state | Wrong profile shown; old closures |
| 7 | Webhook ordering and duplicates | Your copy of a third party's state | Status goes backwards; fulfilled twice |
| 8 | Across processes | A database row | The in-memory fix "worked" but duplicates continue |
3.1 Stale responses (out-of-order resolution)
Search-as-you-type. The user types a, then ab. Two requests go out. Nothing guarantees they come back in the order they were sent. The request for a hits a cold cache and takes 300 ms; ab takes 100 ms.
let shown = '';
async function onInput(q) {
const results = await search(q);
shown = results; // whoever resolves last wins, not whoever was sent last
}
onInput('a');
onInput('ab');
// 400 ms later: shown === 'results for "a"'
The fixes, both verified (the buggy version shows "a", both fixes show "ab"):
// Fix 1: latest wins
let latest = 0;
async function onInput(q) {
const id = ++latest;
const results = await search(q);
if (id !== latest) return; // a newer request exists; drop this response
shown = results;
}
// Fix 2: abort the previous request
let controller;
async function onInput(q) {
controller?.abort();
controller = new AbortController();
const { signal } = controller;
try {
const results = await search(q, { signal });
if (signal.aborted) return; // resolved, but aborted before we resumed (Chapter 6)
shown = results;
} catch (err) {
if (err.name === 'AbortError') return;
throw err;
}
}
The abort version also stops the work: the browser closes the connection, and the server can stop too, if it listens (Chapter 6). Add a debounce in front so you send fewer requests (Chapter 7), but a debounce alone does not fix this race. It makes it rarer, which makes it harder to reproduce.
3.2 Check-then-act across an await
The wallet in Part 2 is the classic case. The second most common is "create if not exists":
async function getOrCreateCustomer(email) {
const existing = await db.customers.findByEmail(email);
if (existing) return existing;
return db.customers.insert({ email }); // two concurrent signups both reach here
}
await Promise.all([getOrCreateCustomer('a@x.io'), getOrCreateCustomer('a@x.io')]);
// two rows for one email
The same shape appears as: "grant the referral bonus if not granted yet", "create the Stripe customer if the user has none", "start the job if it is not already running", "send the welcome email if not sent".
findByEmail → noneThe fix is to let the database make the check and the write one atomic step: a unique constraint plus INSERT … ON CONFLICT DO NOTHING. For an external system that has no constraint you can use (creating a Stripe customer), use that system's idempotency key, or claim the right to create it in your own database first.
3.3 Read-modify-write: lost updates
async function recordUsage(userId, tokens) {
const used = await kv.get(`usage:${userId}`);
await kv.set(`usage:${userId}`, used + tokens);
}
await Promise.all([recordUsage('u1', 100), recordUsage('u1', 250)]);
// stored 250, expected 350: the 100 is lost
Unlike check-then-act, nothing is rejected and nothing is duplicated. A change just disappears, with no error. That makes lost updates the hardest race to notice. The usual symptom is a total that is slightly too low.
GET → 0Fixes, in order of preference: an atomic operation in the store (INCRBY, UPDATE … SET used = used + $1, $inc in MongoDB, HINCRBY); optimistic concurrency, where you write only if the version is unchanged (Part 7); or a lock around the read and the write. The same race hits whole documents: two admins edit the same settings JSON, and the second save wipes the first admin's change. Use a version column there.
3.4 Double submit and duplicate jobs
The user double-taps "Generate". The mobile client retries a POST after a timeout, although the first attempt succeeded. A queue redelivers a job because the worker's acknowledgement was lost. Each is the same operation arriving twice, and the server cannot tell from the request body alone.
Disabling the button while the request is pending is good UX, but it is not a fix: it does nothing about client retries, two tabs, or a script. The server-side fix is an idempotency key: the client creates a UUID when the user starts the action, and sends it with every attempt. The server stores it with a unique constraint, in the same transaction as the effect, and stores the response so that a repeat gets the same answer. While the first request is still in progress, a repeat should get 409 or wait, never start a second run.
3.5 Initialisation races
let client;
async function getClient() {
if (!client) client = await connect(); // check-then-act on `client`
return client;
}
const [a, b] = await Promise.all([getClient(), getClient()]);
// connect() called twice; a !== b; the first client is overwritten and leaked
Both callers see client undefined, both start connect(), and the slower one overwrites the faster. You get two pools, one of them leaked with its connections still open. The same bug hits lazy-loaded config, a JWKS fetch, a vector index loaded on first use, and OAuth token refresh.
client empty, calls connect()Cache the promise, not the result. The assignment then happens synchronously, before any yield point, so run-to-completion makes it atomic. And clear it on failure, or one ECONNREFUSED at boot is cached forever (verified: the first call fails, the second retries and succeeds):
let clientPromise;
export function getClient() {
clientPromise ??= connect().catch((err) => {
clientPromise = undefined; // let the next caller retry
throw err;
});
return clientPromise;
}
3.6 React effect races
React components hit three race shapes.
Stale responses in useEffect. It is 3.1 again: the id prop changes from 1 to 2, the response for 1 arrives last, and the panel shows customer 1 under customer 2's heading.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/customers/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then(setCustomer)
.catch((err) => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort(); // runs before the next effect, and on unmount
}, [id]);
Stale closures. A handler captures the state value from the render that created it. After an await, that value can be old:
const [credits, setCredits] = useState(0);
async function onBuy() {
await api.buyCredits(10);
setCredits(credits + 10); // two quick clicks: both closures saw 0, so the screen shows 10
}
setCredits((c) => c + 10) fixes the arithmetic. It is usually better to show the balance the server returns, since the server is the source of truth. If you do, remember that two responses can arrive out of order, so keep the one with the higher ledger version.
State updates after unmount. React 18 removed the warning, and the update is a no-op, but the request still runs, and any side effect in its .then (a toast, analytics, a redirect) still fires after the user has left the page. Cleanup functions handle this. In development, StrictMode mounts, unmounts and re-mounts every effect once, exactly so that missing cleanup shows up as a double fetch (Chapter 6).
3.7 Webhook ordering and duplicates
Payment providers such as Stripe state that webhook events may arrive more than once and out of order. Each delivery is a separate HTTP request, so two deliveries can run concurrently on two instances.
subscription.updated (created 10:00:05, status active)The rules for webhook handlers:
- Dedupe by event ID:
INSERT INTO processed_events (event_id) …with a primary key, in the same transaction as the effect. A duplicate fails the insert and the effect rolls back with it. - Never trust arrival order. Guard writes on the event's own timestamp or version. Stripe's
createdhas one-second resolution, so ties are possible. For important state, treat the event as a hint and fetch the current object from the provider's API. - Persist before you acknowledge. Return
2xxonly after the event is stored; otherwise a crash loses it. Do slow work from a queue (gym exercise05-webhook-handler).
3.8 Races across processes
You fix the wallet with an in-memory lock per user. Tests pass. In production you run three instances behind a load balancer. Two charges for the same user land on different instances, and each instance has its own Map of locks. Neither lock knows about the other request.
withLock('u1') acquired (its own Map)When the state lives in a database, the fix lives in the database:
| Tool | Use it for | SQL |
|---|---|---|
Atomic conditional UPDATE | Check-then-act on one row | UPDATE wallets SET balance = balance - $2 WHERE user_id = $1 AND balance >= $2 RETURNING balance |
| Unique constraint | "Create if not exists", dedupe, idempotency keys | INSERT … ON CONFLICT (key) DO NOTHING RETURNING id |
SELECT … FOR UPDATE | Multi-step logic on rows you can name | Lock the rows, decide in JS, write, commit |
| Advisory lock | A lock on something that is not a row ("generate the monthly report for org 9") | SELECT pg_advisory_xact_lock(hashtextextended('report:9', 0)) |
FOR UPDATE SKIP LOCKED | Workers claiming jobs without blocking each other | SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED |
| Version column | Lost updates on edited records | UPDATE docs SET body = $2, version = version + 1 WHERE id = $1 AND version = $3 |
CHECK constraint | A backstop when every other fix fails | balance int NOT NULL CHECK (balance >= 0) |
All seven were run against PostgreSQL 18 with two concurrent sessions: the conditional UPDATE in the second session waited for the first to commit, then updated 0 rows; SKIP LOCKED gave two workers disjoint jobs; the duplicate idempotency key returned no row; the stale version updated 0 rows.
Two cautions. Never hold a row lock or a transaction open across an LLM call. A 30-second generation inside BEGIN … COMMIT holds a connection and the row lock for 30 seconds, and the pool runs dry under load. Charge (or reserve credits) in one short transaction, call the model outside it, then settle in another. And session-level advisory locks (pg_advisory_lock) belong to the connection, so with a pooler in transaction mode they can be released by, or leak to, another client. Prefer the _xact_ variants, which are released at commit.
Part 4: The JS ↔ Postgres Parallel
A race in JavaScript and a race between database transactions are the same bug. If you can explain one, you can explain the other, and interviewers like to hear you make the link.
| In one Node process | In PostgreSQL (READ COMMITTED, the default) |
|---|---|
| A synchronous stretch of JS runs with no interleaving | One SQL statement is atomic |
An await between the read and the write | Two separate statements: a SELECT then an UPDATE |
Check-then-act across an await | SELECT balance → decide in the app → UPDATE balance = 20: both transactions read 100 (verified) |
count = await get(); await set(count + 1) | UPDATE … SET n = $computed from an earlier SELECT: a lost update |
x += 1 with no await | UPDATE … SET n = n + 1: the database reads and writes in one step |
| A promise-chain mutex per key | SELECT … FOR UPDATE on the row, or pg_advisory_xact_lock(key) |
| Caching the promise (single-flight) | INSERT … ON CONFLICT DO NOTHING: exactly one caller wins |
| A version check before writing | UPDATE … WHERE version = $read_version |
| A semaphore of size N | A worker pool claiming jobs with SKIP LOCKED |
| A latest-wins token | UPDATE … WHERE last_event_at < $new_event_at |
| An in-memory lock does nothing across instances | A lock in one database does nothing across two databases |
The rule in both worlds: make the critical section a single atomic step, or hold a lock that every participant must take.
Part 5: How to Spot Races When Reading Code
You cannot see a timing bug by reading one execution path. You have to imagine two at once. Use this procedure on every function that writes state:
- Find the shared mutable state. Anything that outlives one call: module-level variables, fields on a singleton service,
Mapcaches, React state, rows, Redis keys, files, records at a third party. - Find every
awaitbetween reading that state and writing something that depends on it. Mark them. Each one is a yield point inside a critical section. - At each mark, ask: "What if another call of this same function runs here?" Then: what if a different function that writes the same state runs here? Then: what if this runs on another instance?
Worked example. Mark each yield point:
async function redeemCoupon(userId, code) {
const coupon = await db.coupons.findByCode(code); // (1) read coupon
if (!coupon || coupon.uses >= coupon.maxUses) throw new Error('invalid');
const already = await db.redemptions.find(userId, code); // (2) yield: can another redeem run here?
if (already) throw new Error('already used');
await ledger.credit(userId, coupon.credits); // (3) yield: the other call passes both checks
await db.coupons.update(code, { uses: coupon.uses + 1 }); // (4) RMW on `uses`, from the read at (1)
await db.redemptions.insert({ userId, code }); // (5) the "already used" record comes last
}
Answers: at (2) and (3), a second call from the same user passes both checks, so the user is credited twice (check-then-act). At (4), two different users both write uses + 1 from the same read, so uses goes up by one instead of two, and the coupon can be used beyond maxUses (lost update). The record that would block a repeat, (5), is written last, after the effect. Fix: in one transaction, INSERT INTO redemptions … with a unique (user_id, code) constraint first; then UPDATE coupons SET uses = uses + 1 WHERE code = $1 AND uses < max_uses RETURNING credits; credit only if both succeeded.
Signals that should make you stop and apply the procedure:
| Signal in the code | Likely race |
|---|---|
if (!x) x = await … | Initialisation race (3.5) |
const v = await get(); … await set(f(v)) | Lost update (3.3) |
if (await exists(…)) return; await create(…) | Duplicate create (3.2) |
| A balance, stock or quota read into a JS variable, then written back | Check-then-act (3.2) |
setX(await …) in a handler or effect, with no token, abort or cleanup | Stale response (3.1, 3.6) |
A POST that charges, sends or creates, with no idempotency key | Double submit (3.4) |
| A "done" flag set after the work it guards | Duplicate work on retry |
An in-memory Map used as a lock, a cache or a dedupe set, in a service with more than one instance | Cross-process race (3.8) |
Part 6: How to Test for Races
A test that calls charge() once cannot fail on a race. A test that calls it twice with Promise.all usually passes too, because with a fast fake database the interleaving happens to be safe. You need to control the interleaving.
Deterministic tests with controlled deferred promises
Make the fake dependency return a promise that the test resolves by hand. Then the test, not the scheduler, decides the order of steps. Promise.withResolvers() (Node 22) gives you the promise and its resolve together.
import { test } from 'node:test';
import assert from 'node:assert/strict';
function controlledDb(start) {
const pendingReads = [];
return {
balance: start,
pendingReads,
getBalance() {
const d = Promise.withResolvers();
pendingReads.push(d);
return d.promise; // stays pending until the test resolves it
},
async setBalance(v) { this.balance = v; },
};
}
async function charge(db, amount) {
const balance = await db.getBalance();
if (balance < amount) throw new Error('insufficient credits');
await db.setBalance(balance - amount);
}
test('read, read, write, write: both charges pass', async () => {
const db = controlledDb(100);
const a = charge(db, 80);
const b = charge(db, 80);
assert.equal(db.pendingReads.length, 2); // both are parked at the same await
db.pendingReads[0].resolve(db.balance); // both reads see 100
db.pendingReads[1].resolve(db.balance);
const r = await Promise.allSettled([a, b]);
assert.deepEqual(r.map((x) => x.status), ['fulfilled', 'fulfilled']);
assert.equal(db.balance, 20); // the bug, reproduced on every run
});
test('with the lock, the second read is not issued until the first charge is done', async () => {
const db = controlledDb(100);
const lockedCharge = (amount) => withLock('wallet', () => charge(db, amount));
const a = lockedCharge(80);
const b = lockedCharge(80);
await new Promise((r) => setImmediate(r));
assert.equal(db.pendingReads.length, 1); // b is queued behind a
db.pendingReads[0].resolve(db.balance);
await a;
await new Promise((r) => setImmediate(r));
db.pendingReads[1].resolve(db.balance); // b reads 20
await assert.rejects(b, /insufficient/);
assert.equal(db.balance, 20);
});
The first test documents the race: it passes on the buggy code, every time. The second states the property the fix must have: the second read is not even issued until the first charge has written. Both pass on Node 22 with the withLock from the build below.
Injecting delays
When the code is too tangled for hand-driven deferreds, add random delays to the fake dependencies and run many times, asserting an invariant rather than an exact sequence:
test('random delays, 200 runs: never overspends', async () => {
for (let run = 0; run < 200; run++) {
let stored = 100;
const jitter = () => new Promise((r) => setTimeout(r, Math.random() * 3));
const db = {
async getBalance() { await jitter(); return stored; },
async setBalance(v) { await jitter(); stored = v; },
};
const results = await Promise.allSettled(
Array.from({ length: 5 }, () => withLock('wallet', () => charge(db, 30))),
);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 3);
assert.equal(stored, 10);
}
});
With the lock, this passes. With the lock removed, it fails on the first run (five charges succeed instead of three). Invariants worth asserting: money is conserved, a balance is never negative, a count equals the number of successful calls, each key appears once.
For cross-process races, unit tests are not enough. Write an integration test against a real Postgres that opens two separate connections and runs the two flows on them at the same time. That is what two instances do.
Part 7: Fixes and Their Limits
Every fix removes one of the three ingredients from Part 1. Every fix also has a limit, and naming the limit is what separates a senior answer.
| Fix | Removes | Fixes | Limit |
|---|---|---|---|
| Latest-wins token | The harm of overlap | Stale responses | The old request still runs and costs money. Only for "show the newest". |
AbortController | The overlap | Stale responses, wasted work | A response that has already resolved still resumes your code; check signal.aborted after the await. The server may not stop. |
| Promise-chain mutex per key | The overlap, per key | Check-then-act and RMW in one process | One process only. A hung holder blocks the key. Re-entering the same key deadlocks. |
| Semaphore | Most of the overlap | Overload | Size 1 is a mutex; size N > 1 does not protect consistency at all. |
| Single-flight | Duplicate work | Init races, cache stampedes, token refresh | One process only. Every waiter shares the same rejection. |
| Idempotency key | The duplicate | Double submit, client retries, redelivered jobs and webhooks | Needs durable storage, a unique constraint, a stored response, and a rule for "still in progress". |
| Optimistic concurrency (version) | The stale write | Lost updates on edited records | On conflict you must retry or show the user a conflict. Hot rows mean many retries. |
| Atomic SQL / store operation | The gap | Check-then-act and RMW across processes | Only works when the logic fits in one statement. Otherwise use FOR UPDATE in a short transaction. |
The code for the in-process fixes:
// Single-flight: concurrent callers for the same key share one promise.
const inflight = new Map();
export function singleFlight(key, fn) {
const existing = inflight.get(key);
if (existing) return existing;
const p = Promise.resolve().then(fn).finally(() => {
if (inflight.get(key) === p) inflight.delete(key);
});
inflight.set(key, p);
return p;
}
// Three concurrent singleFlight('cfg', load) calls: load runs once, all three get 'config'.
// After it settles, the next call runs load again: this dedupes, it does not cache.
// Semaphore: at most `max` concurrent holders.
export function createSemaphore(max) {
let active = 0;
const waiters = [];
return async function run(fn) {
if (active >= max) await new Promise((resolve) => waiters.push(resolve));
else active++;
try { return await fn(); }
finally {
const next = waiters.shift();
if (next) next(); // hand our slot straight to the next waiter
else active--;
}
};
}
// 10 tasks through createSemaphore(3), one of them throwing: peak concurrency 3.
// Optimistic concurrency, with node-postgres.
const { rowCount } = await pool.query(
'UPDATE agent_configs SET body = $2, version = version + 1 WHERE id = $1 AND version = $3',
[id, newBody, versionTheEditorLoaded],
);
if (rowCount === 0) throw new ConflictError('Someone else saved this config. Reload and reapply your change.');
And the atomic charge for the credit ledger. The deduction and the ledger row are one statement. A repeated idempotency key violates the unique constraint, which fails the whole statement, so the balance is not touched:
WITH charged AS (
UPDATE wallets SET balance = balance - $2
WHERE user_id = $1 AND balance >= $2
RETURNING user_id, balance
)
INSERT INTO ledger (user_id, amount, idempotency_key)
SELECT user_id, -$2, $3 FROM charged
RETURNING id, (SELECT balance FROM charged) AS balance;
Verified on PostgreSQL 18: the first call returns the new balance 80; the same key again raises duplicate key value violates unique constraint and the balance stays 80; a charge larger than the balance returns no row. In the app, no row means "insufficient credits", and error code 23505 means "already charged": look up and return the original result. Do not use ON CONFLICT DO NOTHING here. A data-modifying CTE runs even when the outer INSERT does nothing, so the wallet would be charged with no ledger row.
Build: A Keyed Async Mutex
Spec. withLock(key, fn) runs fn and returns a promise for its result or its error.
- Calls with the same key run one at a time, in the order they arrived.
- Calls with different keys do not wait for each other.
- If
fnthrows, synchronously or by rejecting, the caller gets that error, and the lock is released for the next waiter. - Keys that are no longer in use are removed, so memory does not grow with the number of users.
Reference solution
const tails = new Map(); // key → promise that settles when the last queued holder is done
export function withLock(key, fn) {
const prev = tails.get(key) ?? Promise.resolve();
const run = prev.then(() => fn()); // start after the previous holder settles
const tail = run.then(noop, noop); // never rejects, so one failure can't poison the queue
tails.set(key, tail);
tail.then(() => {
if (tails.get(key) === tail) tails.delete(key); // last one out removes the key
});
return run; // the caller sees fn's own result or error
}
function noop() {}
export const lockedKeyCount = () => tails.size;
Why it works: tails.get and tails.set happen synchronously, in the same tick, so two callers can never both see the same prev and start together. That is run-to-completion doing the locking. Each caller chains onto the previous tail, which forms a FIFO queue per key. then(() => fn()) turns a synchronous throw into a rejection. Storing run.then(noop, noop) instead of run means a rejected fn releases the lock instead of rejecting every later waiter.
Tests, all passing on Node 22 with node --test:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withLock, lockedKeyCount } from './with-lock.mjs';
const tick = () => new Promise((r) => setImmediate(r));
test('serialises calls on the same key, in arrival order', async () => {
const log = [];
const gates = [Promise.withResolvers(), Promise.withResolvers(), Promise.withResolvers()];
const runs = gates.map((g, i) =>
withLock('user:1', async () => {
log.push(`start ${i}`);
await g.promise;
log.push(`end ${i}`);
}),
);
await tick();
assert.deepEqual(log, ['start 0']); // 1 and 2 are queued, not running
gates[2].resolve();
gates[1].resolve(); // finishing out of order changes nothing
await tick();
assert.deepEqual(log, ['start 0']);
gates[0].resolve();
await Promise.all(runs);
assert.deepEqual(log, ['start 0', 'end 0', 'start 1', 'end 1', 'start 2', 'end 2']);
});
test('different keys run at the same time', async () => {
const gate = Promise.withResolvers();
let bRan = false;
const a = withLock('user:1', () => gate.promise);
const b = withLock('user:2', async () => { bRan = true; });
await b; // b finishes while a still holds user:1
assert.equal(bRan, true);
gate.resolve();
await a;
});
test('releases the lock when fn throws or rejects', async () => {
const first = withLock('k', async () => { throw new Error('boom'); });
const sync = withLock('k', () => { throw new Error('sync boom'); });
const third = withLock('k', async () => 'ran');
await assert.rejects(first, /boom/);
await assert.rejects(sync, /sync boom/);
assert.equal(await third, 'ran');
});
test('returns the result and removes the key afterwards', async () => {
assert.equal(await withLock('x', async () => 42), 42);
await tick();
assert.equal(lockedKeyCount(), 0);
});
test('re-entering the same key deadlocks (documented limitation)', async () => {
const outer = withLock('re', () => withLock('re', async () => 'inner'));
const timeout = new Promise((r) => setTimeout(() => r('deadlock'), 50));
assert.equal(await Promise.race([outer, timeout]), 'deadlock');
});
Edge cases interviewers check:
- Errors: one failed holder must not reject every later waiter (the
nooptail), and the caller must still see its own error. - Memory: without the cleanup, a
Mapwith one entry per user ID grows forever. Thetails.get(key) === tailcheck stops an early finisher from deleting a newer waiter's entry. - Re-entrancy:
fncallingwithLockon its own key waits for itself forever. Say so, and suggest passing a "lock held" context instead of re-locking. - A holder that never settles blocks the key forever. Production versions add a timeout or an
AbortSignalwhile waiting (Chapter 6). - Scope: this serialises calls in one process. With two instances, use the database (3.8). Say this before the interviewer does.
- Throughput: everything for one key is now serial. That is right for one wallet; it is wrong for a global key like
'wallets'.
Investigation: Charged Twice for One Story
-20 rows for the same story request, 30 to 80 ms apart. The charge code has not changed in three months.Spot the Bug
Drill 1: four recovery paths, one refund
A failed generation must be refunded once. Four code paths can call this: the watchdog, the retry-exhausted handler, the admin "refund" button, and startup recovery.
export async function refundIfNeeded(jobId) {
const job = await db.jobs.findById(jobId);
if (job.status !== 'failed' || job.refundedAt) return;
await ledger.credit(job.userId, job.cost, { reason: 'refund', ref: job.id });
await db.jobs.update(jobId, { refundedAt: new Date() });
logger.info({ jobId }, 'refunded');
}
What's wrong?
Answer
Bug. Check-then-act across awaits, and the "done" flag is written after the effect. Two paths that call this for the same job both read refundedAt = null, both pass the check, and both credit. The paths run on different instances (the watchdog runs on one, the admin button on whichever instance serves the click), so an in-memory lock would not help.
Interleaving. Watchdog: findById → not refunded → await ledger.credit (yields). Retry handler: findById → still not refunded, because the watchdog has not reached the update yet → credits again. Both then set refundedAt.
Fix. Claim the refund atomically, and credit in the same transaction:
await withTransaction(pool, async (tx) => {
const { rows } = await tx.query(
`UPDATE jobs SET refunded_at = now()
WHERE id = $1 AND status = 'failed' AND refunded_at IS NULL
RETURNING user_id, cost`,
[jobId],
);
if (rows.length === 0) return; // already refunded, or not failed
await tx.query(
'INSERT INTO ledger (user_id, amount, ref) VALUES ($1, $2, $3)',
[rows[0].user_id, rows[0].cost, `refund:${jobId}`],
);
});
Add a unique constraint on ledger.ref as a second line of defence: even a fifth path written next year cannot pay the same refund twice.
Say it: "The check and the flag are separated by an await and live in different processes, so the claim has to be one conditional UPDATE: whoever gets a row back owns the refund."
Drill 2: the customer panel
function CustomerPanel({ customerId }) {
const [customer, setCustomer] = useState(null);
useEffect(() => {
setCustomer(null);
fetch(`/api/admin/customers/${customerId}`)
.then((r) => r.json())
.then((data) => setCustomer(data));
}, [customerId]);
if (!customer) return <Spinner />;
return <CustomerDetails customer={customer} onRefund={() => refund(customer.id)} />;
}
What's wrong?
Answer
Bug. A stale response. An admin clicks customer 41, then quickly customer 42. If the response for 41 arrives last, the panel shows customer 41 while the list highlights 42. The Refund button then refunds customer 41. Nothing cancels or ignores the old request, and unmounting does not stop it either.
Fix.
useEffect(() => {
const controller = new AbortController();
setCustomer(null);
fetch(`/api/admin/customers/${customerId}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => { if (!controller.signal.aborted) setCustomer(data); })
.catch((err) => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort();
}, [customerId]);
The cleanup runs before the next effect and on unmount. The aborted check covers a response that had already resolved when the abort happened. Better still, check on the server that the refund's customer matches the one the admin confirmed in the UI. Libraries like TanStack Query key results by customerId, which removes this whole class of bug.
Say it: "The effect has no cleanup, so responses for old IDs can land after newer ones; with an AbortController in the cleanup, only the current ID's response can set state."
Drill 3: token refresh
let accessToken = loadToken();
export async function apiFetch(url, opts = {}) {
let res = await fetch(url, withAuth(opts, accessToken));
if (res.status === 401) {
accessToken = await refreshAccessToken(); // POST /oauth/token with the stored refresh token
res = await fetch(url, withAuth(opts, accessToken));
}
return res;
}
The dashboard loads six widgets in parallel. Users report being logged out at random, about once a day.
What's wrong?
Answer
Bug. An initialisation race on the token. When the access token expires, all six requests get 401 at about the same time, and all six call refreshAccessToken() concurrently. With refresh-token rotation, each refresh token can be used once, and providers with reuse detection treat a reused refresh token as theft and revoke the whole session. Request 1's refresh succeeds and rotates the token; requests 2–6 present the old refresh token and the session is revoked. The user is logged out.
Fix. Single-flight the refresh:
let refreshing = null;
function refreshOnce() {
refreshing ??= refreshAccessToken().finally(() => { refreshing = null; });
return refreshing;
}
// in apiFetch:
if (res.status === 401) {
accessToken = await refreshOnce();
res = await fetch(url, withAuth(opts, accessToken));
}
Two browser tabs are two "processes" with the same problem. Coordinate them with the Web Locks API (navigator.locks.request('token-refresh', …)) or a BroadcastChannel.
Say it: "Six concurrent 401s each start a refresh; with rotating refresh tokens only the first is valid and the rest revoke the session, so the refresh has to be single-flight."
Drill 4: a daily quota in Redis
export async function consumeQuota(userId) {
const key = `quota:${userId}:${todayUtc()}`;
const used = Number(await redis.get(key)) || 0;
if (used >= DAILY_LIMIT) throw new QuotaExceededError();
await redis.set(key, used + 1, 'EX', 86_400);
}
What's wrong?
Answer
Bugs. Two races on one key. It is a check-then-act: ten concurrent requests at used = 99 with a limit of 100 all pass the check. And it is a read-modify-write: they all write 100, so nine uses vanish from the count. Running more instances makes both worse. A third, smaller bug: every SET … EX resets the TTL, so the window keeps sliding.
Fix. Let Redis do the read, the add and the check in one atomic step, and set the TTL only on creation:
export async function consumeQuota(userId) {
const key = `quota:${userId}:${todayUtc()}`;
const [[, used]] = await redis.multi().incr(key).expire(key, 86_400, 'NX').exec();
if (used > DAILY_LIMIT) throw new QuotaExceededError();
}
INCR returns the new value atomically, so each request gets a distinct count, and exactly DAILY_LIMIT of them pass. MULTI makes the INCR and the EXPIRE one unit, so a crash cannot leave a key with no TTL. EXPIRE … NX needs Redis 7. Over-limit requests still increment the counter, which is usually acceptable for a quota.
Say it: "GET then SET is a read-modify-write across an await, so concurrent requests all read the same count; INCR makes the increment and the read one atomic operation."
The Causal Chains
- If a critical section contains an
await - If responses can resolve out of order and each one writes to the UI
- If a lazily created client is cached only after
await connect() - If you fix a cross-instance race with an in-memory mutex
- If two transactions
SELECTa balance and thenUPDATEit with a value computed in the app - If a
POSTthat charges has no idempotency key - If a webhook handler applies events in arrival order
- If a race test uses fast fakes and
Promise.all
Review Checklist
- List the shared mutable state: module-level
let, singleton fields,Mapcaches, React state, rows, Redis keys. For each, find every writer. - For each writer, mark every
awaitbetween the read and the dependent write. Ask "what if another call runs here?" and "what if it runs on another instance?". if (!x) x = await …: cache the promise instead.getfollowed byseton the same key: use an atomic operation or a version check.- A balance, quota or stock read into JS and written back: use one conditional
UPDATE … WHERE … RETURNING. - "Create if not exists" with no unique constraint behind it.
- Charging, sending or creating
POSTendpoints, and job handlers, with no idempotency key; "done" flags written after the effect. setState(await …)in effects and handlers with no cleanup, abort or latest-wins check.- Webhook handlers: dedupe by event ID? Guarded by event time? Persist before
2xx? - Any in-memory lock, dedupe set or rate limiter in a service that runs on more than one instance.
What's Next
Several fixes in this chapter depend on stopping work that is no longer wanted: the old search request, the losing provider call, the effect for an unmounted component. Chapter 6 — Cancellation and Cleanup covers AbortController in depth, along with the abort race that the signal.aborted checks above guard against.