How to detect race conditions in production web apps.

A race condition passes your tests, throws no error and shows the user the wrong data. To find one on a live site you have to record the order things happened in. Here are three checks you can build, and what to log.

Why race conditions reach production

Tests run on a fast network. Responses come back in the order the requests went out, so the code looks correct. Real networks reorder responses. One request hits a cold cache or a slow connection, and the answer to an older question arrives last.

Take a search box. The user types "pa", then finishes the word: "paris". Two requests are in flight. The response for "paris" arrives first and its results render. Then the response for "pa" lands and overwrites them. The box says "paris" and the list shows results for "pa".

Nothing threw. Both requests returned 200, so an error tracker has nothing to report. The same thing happens with filters, tabs, pagination and autosave. The fixes are in out-of-order responses in search and autocomplete and race conditions in React. This guide is about finding the ones that already shipped.

What detecting a race means

A crash gives you a stack trace. A race gives you none, because every step is correct when you look at it alone. The bug is the order. So detecting a race means two things: record the order of requests, responses and state writes, then flag the orders that should not happen. An older response written over a newer one is one such order. The same write sent twice is another.

A team can build three checks for this. Each is a few lines around fetch and your store.

1. Number every request

Give each request a sequence number and a key that names what it loads, such as "search" or "cart". When a response arrives, check that its request is still the latest for that key. If it is not, log it.

let seq = 0;
const latest = new Map();          // key: sequence number of the newest request

async function trackedFetch(key, url, init) {
  const req = {
    seq: ++seq,
    key,
    route: new URL(url, location.href).pathname,
    startedAt: performance.now(),
  };
  latest.set(key, req.seq);
  const res = await fetch(url, init);
  const newest = latest.get(key);
  if (newest !== req.seq) {
    report({
      type: "superseded-response",
      key,
      route: req.route,
      seq: req.seq,
      newestSeq: newest,
      tookMs: Math.round(performance.now() - req.startedAt),
    });
  }
  return { res, req };
}

This counts how often a response comes back after a newer request has started. That is not always a bug: if the code drops the old response, the user sees nothing wrong. It shows where a race can happen. The next check shows where one did.

2. Version state writes

Record which request wrote each value in your store. Before a write, compare the incoming request with the one that wrote the current value. Sequence numbers are handed out when a request starts, so a lower number means an earlier start. If the incoming request started earlier, an older response is about to replace newer data.

const writers = new Map();         // field: the request that wrote the current value

function trackedWrite(field, value, req) {
  const prev = writers.get(field);
  const now = performance.now();
  if (prev && req.seq < prev.seq) {
    report({
      type: "stale-write",
      field,
      won: { route: req.route, seq: req.seq, startedAt: Math.round(req.startedAt) },
      lost: { route: prev.route, seq: prev.seq, startedAt: Math.round(prev.startedAt) },
      gapMs: Math.round(now - prev.wroteAt),
    });
  }
  writers.set(field, { ...req, wroteAt: now });
  store.setState({ [field]: value });
}

async function search(q) {
  const { res, req } = await trackedFetch("search", `/api/search?q=${encodeURIComponent(q)}`);
  trackedWrite("results", await res.json(), req);
}

In the search example this fires once. The write from "pa" carries a lower number than the write from "paris" that is already in the store. That is the bug, reported at the moment it happens.

3. Log duplicate mutations

A duplicate is the same method, URL and body sent twice within a short window. Track the last user action as well. Two sends with no action between them point to a retry or an effect that ran twice, and this check flags those. Two sends with a click between them are a double click. The fixes for both are in preventing duplicate form submissions.

let lastAction = 0;
for (const type of ["click", "keydown", "submit"]) {
  addEventListener(type, () => { lastAction = performance.now(); }, true);
}

const recent = new Map();          // signature: { at, seq } of the last send
const WINDOW_MS = 2000;

function checkDuplicate(method, url, body, seq) {
  if (method === "GET" || method === "HEAD") return;
  if (body != null && typeof body !== "string") return;   // compare only bodies we can read
  const now = performance.now();
  const sig = `${method} ${url} ${body ?? ""}`;
  const prev = recent.get(sig);
  if (prev && now - prev.at < WINDOW_MS && lastAction <= prev.at) {
    report({
      type: "duplicate-mutation",
      method,
      route: new URL(url, location.href).pathname,
      firstSeq: prev.seq,
      secondSeq: seq,
      gapMs: Math.round(now - prev.at),
    });
  }
  recent.set(sig, { at: now, seq });
  setTimeout(() => { if (recent.get(sig)?.seq === seq) recent.delete(sig); }, WINDOW_MS);
}

// in trackedFetch, just before fetch(url, init):
checkDuplicate((init?.method ?? "GET").toUpperCase(), String(url), init?.body, req.seq);

The body is compared in the page and never leaves it. Only the method, the route and the gap go into the report.

What to send to your logs

A report should let someone see the order without seeing the user's data. Send four things:

  • The route: the URL path, with no query string.
  • The two requests: the method, key and sequence number of each.
  • Timings: when each one started, when each one landed, and the gap.
  • Which one won: the request whose value is in the store now.
function report(event) {
  const body = JSON.stringify({ ...event, page: location.pathname, at: Date.now() });
  navigator.sendBeacon("/log/races", body);
}
Privacy: never send page text, request or response bodies, or query strings. They hold names, emails, search terms and tokens. Paths can hold IDs too, so swap those for a placeholder. Order and timing are enough to show a race.

Limits of doing it yourself

  • Every call has to be wrapped. One plain fetch in a new feature, or one direct store write, and that path is invisible. XHR, WebSocket messages and requests made inside libraries need their own wrappers.
  • It needs maintenance. Someone picks the keys and field names by hand and keeps them right as the app changes. A key that is too broad invents races. A key that is too narrow hides them.
  • Thresholds produce false alarms. A two-second window flags a heartbeat POST that is meant to repeat. Start order is also only a stand-in for data age: a request that started first can carry newer data if the server answered it later.
  • A report is not a diagnosis. The checks say an order looked wrong. Someone still has to decide whether the user saw a wrong screen.

Run a runtime monitor

GenClass is an open-source browser runtime (Apache-2.0) that watches requests and state writes, and uses a small on-device model to report stale responses, duplicate submits and write races with the evidence. Its default mode only reports and changes nothing in your app. One command sets it up: npx genclass. Read how GenClass detects it, and see the measured numbers on the benchmarks.

Related: race conditions in React, out-of-order responses and preventing duplicate form submissions.