Vai al contenuto
Clinford Jordano Valencia
Tutti gli articoli

JavaScript

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 di lettura

Questa pagina non è ancora disponibile in italiano, quindi stai leggendo l’originale in inglese.

“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 -60
Call ACall Bcheck100 ≥ 80 ✓check100 ≥ 80 ✓balance −= 8020balance −= 80−60A waits for the server……B waits too
Two calls interleave at the await. Both pass the check before either one updates the balance.

The 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

RaceWhat happensTypical place
Lost updateTwo operations read, modify and write the same state; one overwrites the otherCounters, carts, form autosave
Stale responseAn older, slower request finishes last and overwrites newer resultsSearch-as-you-type, filters, tab switches
Double submissionThe same action runs twicePayment and order buttons, retries
Initialization raceTwo callers both see “not loaded yet” and both loadLazy singletons, token refresh
Cross-tab and cross-deviceTwo browser tabs or devices edit the same recordSettings 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 20

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

PatternWhat it doesWhat it solves
Reserve before you await (this one)Checks and updates local state in one synchronous step, then awaits; rolls back on failureTwo local operations passing the same check
Optimistic UI updateShows the expected result before the server confirms, and reverts it on failureThe UI feeling slow while waiting
Optimistic concurrency controlThe server accepts a write only if the record still has the version you readRaces 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.
Without a lock: the two calls overlapA: check · await · updateB: check · await · updateB checks before A updatesWith a mutex: B starts after A finishesA: check · await · updateB waits in the queueB: check · await · update
Without a lock, B checks the balance while A is still waiting. With a mutex, B only starts after A has finished.

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 20

How it works:

  • #last is the promise of the most recent task in the queue.
  • Each run chains its task onto it, so the task starts only after the previous one settles, then becomes the new end of the queue.
  • The catch matters: 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 settles

3. 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 runs

Web 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

Client 1Client 2Storagelock, token 33paused (GC, slow network)lock, token 34write 34: acceptedwrite, token 3333 < 34: rejected
Why a lock alone isn’t enough: client 1 pauses, its lock expires, and client 2 takes over. A fencing token lets storage reject the late write from client 1.

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.

Sfida 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);
Mostra risposta

1

  • All three calls read count (0) before any of them resumes after the await.
  • Then each writes 0 + 1, so two updates are lost.
  • This is the read-await-write shape in its smallest form.

Sfida 2

The Same Increments, Reordered

let count = 0;
async function increment() {
  await null;
  count++;
}
await Promise.all([increment(), increment(), increment()]);
console.log(count);
Mostra risposta

3

  • The read and the write (count++) now happen together, synchronously, after the await.
  • Nothing can run between them, so no update is lost.
  • Moving the await so that check and update are never separated is often the whole fix.

Sfida 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);
Mostra risposta

react

  • The slow re request finishes last, but its id (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.

Sfida 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);
Mostra risposta

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 pending on failure, so a later call can retry.

Sfida 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);
Mostra risposta

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 AbortSignal into 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

Tag

Domande frequenti

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.