42 min read

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.

TermMeaning
Race conditionA bug where the outcome depends on the timing of interleaved async steps.
InterleavingOne particular order in which the steps of two or more async flows run.
Run-to-completionA JS callback or synchronous stretch of code runs to its end before any other JS on the same thread starts.
Critical sectionThe 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.
AtomicIndivisible: no other operation can observe or act in the middle of it.
Check-then-actRead 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 updateTwo RMWs on the same value; the second write overwrites the first, and one change disappears.
MutexA lock that admits one holder at a time.
SemaphoreA lock that admits up to N holders at a time.
Single-flightConcurrent callers asking for the same thing share one in-flight promise.
Idempotency keyA unique ID for one logical operation, so that repeating it has no extra effect.
Optimistic concurrencyWrite 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:

  1. 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.
  2. At least two flows that touch it. Two requests, two clicks, two webhook deliveries, a timer and a request, two instances of your service.
  3. 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:

js
// 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:

js
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 inBetween almost any two machine instructionsOnly at await, callbacks and events
Is x = x + 1 atomic?NoYes, if there is no await in it
Can you see the yield points?NoYes: every one is written in the code
Main toolLocks around memoryKeep 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.

#
Request A
Request B
wallet.balance
1
reads balance → 100
100
1 / 6

Now drag the latencies yourself. Try to find the timing where both charges succeed, then turn on the fix.

Loading simulation…

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".

#ShapeShared stateTypical symptom
1Stale responsesWhat is on screenThe UI shows results for an old query
2Check-then-actA balance, a stock count, "does it exist?"Overspend, duplicate rows
3Read-modify-writeA counter, a JSON blob, a listIncrements or edits go missing
4Double submitThe operation itselfTwo orders, two charges, two jobs
5InitialisationA lazily created clientTwo connections, a leaked pool
6React effectsComponent stateWrong profile shown; old closures
7Webhook ordering and duplicatesYour copy of a third party's stateStatus goes backwards; fulfilled twice
8Across processesA database rowThe 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.

js
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"'
#
search("a")
search("ab")
Screen
1
sent at 0 ms
empty
1 / 6

The fixes, both verified (the buggy version shows "a", both fixes show "ab"):

js
// 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.

Loading simulation…

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":

js
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".

#
Signup A
Signup B
customers table
1
findByEmail → none
0 rows
1 / 6

The 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

js
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.

#
recordUsage(100)
recordUsage(250)
usage:u1
1
GET → 0
0
1 / 6

Fixes, 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.

Loading simulation…

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.

#
Tap 1
Tap 2
Server
1
POST /generate
starts generation #1, charges 20
1 / 5

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.

Loading simulation…

3.5 Initialisation races

js
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.

#
Caller 1
Caller 2
client
1
client empty, calls connect()
undefined
1 / 5

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):

js
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.

jsx
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:

jsx
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.

#
Delivery 1
Delivery 2
subscriptions.status
1
subscription.updated (created 10:00:05, status active)
past_due
1 / 5

The rules for webhook handlers:

  1. 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.
  2. Never trust arrival order. Guard writes on the event's own timestamp or version. Stripe's created has 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.
  3. Persist before you acknowledge. Return 2xx only after the event is stored; otherwise a crash loses it. Do slow work from a queue (gym exercise 05-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.

#
Instance 1
Instance 2
wallets row u1
1
withLock('u1') acquired (its own Map)
100
1 / 7

When the state lives in a database, the fix lives in the database:

ToolUse it forSQL
Atomic conditional UPDATECheck-then-act on one rowUPDATE wallets SET balance = balance - $2 WHERE user_id = $1 AND balance >= $2 RETURNING balance
Unique constraint"Create if not exists", dedupe, idempotency keysINSERT … ON CONFLICT (key) DO NOTHING RETURNING id
SELECT … FOR UPDATEMulti-step logic on rows you can nameLock the rows, decide in JS, write, commit
Advisory lockA 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 LOCKEDWorkers claiming jobs without blocking each otherSELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED
Version columnLost updates on edited recordsUPDATE docs SET body = $2, version = version + 1 WHERE id = $1 AND version = $3
CHECK constraintA backstop when every other fix failsbalance 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 processIn PostgreSQL (READ COMMITTED, the default)
A synchronous stretch of JS runs with no interleavingOne SQL statement is atomic
An await between the read and the writeTwo separate statements: a SELECT then an UPDATE
Check-then-act across an awaitSELECT 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 awaitUPDATE … SET n = n + 1: the database reads and writes in one step
A promise-chain mutex per keySELECT … 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 writingUPDATE … WHERE version = $read_version
A semaphore of size NA worker pool claiming jobs with SKIP LOCKED
A latest-wins tokenUPDATE … WHERE last_event_at < $new_event_at
An in-memory lock does nothing across instancesA 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:

  1. Find the shared mutable state. Anything that outlives one call: module-level variables, fields on a singleton service, Map caches, React state, rows, Redis keys, files, records at a third party.
  2. Find every await between reading that state and writing something that depends on it. Mark them. Each one is a yield point inside a critical section.
  3. 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:

js
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 codeLikely 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 backCheck-then-act (3.2)
setX(await …) in a handler or effect, with no token, abort or cleanupStale response (3.1, 3.6)
A POST that charges, sends or creates, with no idempotency keyDouble submit (3.4)
A "done" flag set after the work it guardsDuplicate work on retry
An in-memory Map used as a lock, a cache or a dedupe set, in a service with more than one instanceCross-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.

js
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:

js
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.

FixRemovesFixesLimit
Latest-wins tokenThe harm of overlapStale responsesThe old request still runs and costs money. Only for "show the newest".
AbortControllerThe overlapStale responses, wasted workA response that has already resolved still resumes your code; check signal.aborted after the await. The server may not stop.
Promise-chain mutex per keyThe overlap, per keyCheck-then-act and RMW in one processOne process only. A hung holder blocks the key. Re-entering the same key deadlocks.
SemaphoreMost of the overlapOverloadSize 1 is a mutex; size N > 1 does not protect consistency at all.
Single-flightDuplicate workInit races, cache stampedes, token refreshOne process only. Every waiter shares the same rejection.
Idempotency keyThe duplicateDouble submit, client retries, redelivered jobs and webhooksNeeds durable storage, a unique constraint, a stored response, and a rule for "still in progress".
Optimistic concurrency (version)The stale writeLost updates on edited recordsOn conflict you must retry or show the user a conflict. Hot rows mean many retries.
Atomic SQL / store operationThe gapCheck-then-act and RMW across processesOnly works when the logic fits in one statement. Otherwise use FOR UPDATE in a short transaction.

The code for the in-process fixes:

js
// 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:

sql
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 fn throws, 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
js
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:

js
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 noop tail), and the caller must still see its own error.
  • Memory: without the cleanup, a Map with one entry per user ID grows forever. The tails.get(key) === tail check stops an early finisher from deleting a newer waiter's entry.
  • Re-entrancy: fn calling withLock on 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 AbortSignal while 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

InvestigationTime spent: 0 min
Symptom
Since Tuesday's mobile release, support has 23 tickets saying "I was charged twice for one story". The ledger has two -20 rows for the same story request, 30 to 80 ms apart. The charge code has not changed in three months.
1What do you check first?

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.

js
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:

js
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

jsx
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.

jsx
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

js
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:

js
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

js
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:

js
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

Self-test: predict each consequence before revealing it.
  • 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 SELECT a balance and then UPDATE it with a value computed in the app
  • If a POST that 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

  1. List the shared mutable state: module-level let, singleton fields, Map caches, React state, rows, Redis keys. For each, find every writer.
  2. For each writer, mark every await between the read and the dependent write. Ask "what if another call runs here?" and "what if it runs on another instance?".
  3. if (!x) x = await …: cache the promise instead.
  4. get followed by set on the same key: use an atomic operation or a version check.
  5. A balance, quota or stock read into JS and written back: use one conditional UPDATE … WHERE … RETURNING.
  6. "Create if not exists" with no unique constraint behind it.
  7. Charging, sending or creating POST endpoints, and job handlers, with no idempotency key; "done" flags written after the effect.
  8. setState(await …) in effects and handlers with no cleanup, abort or latest-wins check.
  9. Webhook handlers: dedupe by event ID? Guarded by event time? Persist before 2xx?
  10. 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.