A screen shows stale data when two requests finish in a different order from the one in which they started. The test usually passes because the network happened to be fast that day. Then someone runs it repeatedly, hoping the race will appear.

That test does not choose the schedule you need to investigate. To test a race condition in JavaScript, keep the async operations pending and release each one in the suspected order. The assertion should check the system invariant, not merely that two functions were called.

I ran this article's fixture with Node.js v25.5.0. It uses node:test, deferred Promises, and in-memory state. The example proves an update rule inside one process. It does not prove database isolation, a distributed lock, queue delivery, or real browser behavior.

Diagram showing the second Promise resolving first while a race-condition test blocks the older result.

Short answer

  • Do not use random setTimeout calls to reproduce the race.
  • Create pending Promises and control when each operation finishes.
  • Write the invariant that must remain true after each relevant order.
  • Repeat the proof at the real boundary when the risk involves a database, queue, or browser.

What does a race-condition test need to prove?

It needs to prove a claim about a possible order. For example, when a user makes two searches, the newest search may update the screen, but an older response must not overwrite it later. The test should force that order and check the final state.

A useful claim also says what must not happen. "Two Promises finished" describes setup. "The old result does not change the current state" describes the protection. That distinction prevents a green test from ever reaching the dangerous effect.

The Node.js test runner, retrieved September 29, 2026, treats a rejected Promise returned by a test as a failure. That lets you write an ordinary async fixture without hiding the execution order behind a separate callback.

Why does Promise.all with a delay often miss it?

Promise.all waits for every operation, but it does not choose which one finishes first. A setTimeout can make one outcome more likely, but it does not turn the sequence into a proof. The test still depends on the clock, machine load, and schedule selected by the environment.

This pattern looks concurrent, but it does not control the race:

const result = await Promise.all([
  load('first'),
  load('second'),
]);

That code is correct when you only need both results. It is not enough when the rule depends on completion order. Running the case in a loop does not close the gap either. The loop can exercise the same order hundreds of times and never reach the interleaving that causes the bug.

Doppler's guide to reliably testing race conditions, retrieved September 29, 2026, uses the same principle inside a transaction: the test holds intermediate points until both operations have completed the necessary read. The useful detail is not the specific database. It is making the schedule a controllable test input.

How do you control Promise completion?

A deferred Promise exposes its resolve function to the test. The operation starts and pauses until the case releases the chosen point. The test can start the old search, start the new one, resolve the new one, and only then resolve the old one.

function deferred() {
  let resolve;
  const promise = new Promise((value) => {
    resolve = value;
  });

  return { promise, resolve };
}

Now create a function that keeps only the latest request's result. The counter is a local version of the intent. Each call captures its version before waiting for the read.

function createLatestLoader(read) {
  let version = 0;
  let current;

  return {
    load(key) {
      const requestVersion = ++version;

      return read(key).then((value) => {
        if (requestVersion === version) current = value;
        return value;
      });
    },
    current() {
      return current;
    },
  };
}

The guard does not cancel the old request. It prevents a late completion from changing state that already belongs to a newer intent. In another system, the protection could be an AbortController, a persisted version, or a transaction. The invariant is the same only when the system contract says it is.

How do you write the test that reproduces the dangerous order?

The test starts both operations without awaiting them immediately. It releases the second one and waits for it to finish. Finally, it releases the first one and checks that the old value did not replace the new one.

import test from 'node:test';
import assert from 'node:assert/strict';

test('a late response cannot overwrite the latest result', async () => {
  const first = deferred();
  const second = deferred();
  const loader = createLatestLoader((key) =>
    key === 'first' ? first.promise : second.promise,
  );

  const firstRun = loader.load('first');
  const secondRun = loader.load('second');

  second.resolve('new');
  await secondRun;
  first.resolve('old');
  await firstRun;

  assert.equal(loader.current(), 'new');
});

I ran the logic locally with node --input-type=module and an equivalent version passed through standard input. In a test file, the same case can be run with node --test. It passed. That confirms the fixture and guard for this order. It is not a latency measurement and does not prove that a remote server cancelled any work.

To prove that the test reaches the defect, temporarily remove if (requestVersion === version) and always assign current = value. The same order should fail because the old response finishes last and overwrites the new value. A test that stays green after removing the protection never reached the line that matters.

Which orders and failures belong in the matrix?

Start with the order that motivated the bug, but do not stop there. A small matrix can cover the first operation finishing before the second, the second finishing before the first, a failure in the old operation, and a failure in the new one. Each scenario should map to an observable decision.

Scenario Setup Invariant or result
first finishes first release first, then second final state follows the product rule
second finishes first release second, then first the old result cannot overwrite current state
old read fails reject first after the second the old failure does not erase new state
new read fails reject second before the first the system shows an error or fallback without accepting stale data
cancellation abort the operation that is no longer relevant the test distinguishes cancellation from an unexpected failure

Do not turn every combination into a unit test. The comparison of unit and integration tests in Node.js helps decide which collaborator must remain real. The in-memory fixture proves the controller's decision. An integration test proves the serialization, database, queue, or transport that also participates in the race. When the dispute involves the same API intent, test idempotency without duplicate side effects at the real boundary.

Do fake timers solve this problem?

Sometimes. Fake timers are useful when behavior depends on setTimeout, backoff, debounce, or an interval. The current Node.js timers documentation, retrieved September 29, 2026, describes timer APIs and the Promise-based variant. The guide to testing JavaScript retries without waiting for the real clock covers that boundary.

A race between two responses may not depend on time. It depends on which completion crosses the next step first. In that case, controlling the Promises communicates the cause more directly and avoids a test that looks fast while still hiding the order.

If the function schedules a timer and also waits for an external Promise, control both separately. Advancing the clock does not automatically settle every Promise queue, and resolving the Promise does not fire a timer that is still pending. Make each state change explicit before the assertion.

What does the fixture not prove in production?

A deterministic fixture does not replace a test at the boundary where the race causes damage. A local Map does not simulate two instances, a concurrent transaction, message visibility, or request cancellation in a browser.

Use the fixture to prove the algorithm and state contract. Then add a test with the storage, broker, HTTP client, or browser that actually participates in the operation. Record which part stayed real and which part remained controlled, because the two tests answer different questions.

That separation also prevents a false sense of coverage. The paper An Empirical Study of Flaky Tests in JavaScript, retrieved September 29, 2026, studies flakiness in JavaScript projects. It is evidence that test reliability is a real problem, not proof that this fixture resolves every race.

Frequently Asked Questions

Do I need to run the test 1,000 times?

Not to prove an order you can control. Release the deferred Promises in the suspected sequence and make a deterministic assertion. Repetition can complement an environment test, but it should not be the only way to reach a specific interleaving.

Do race conditions require multiple threads?

No. Two async operations can interleave at their await points and produce incorrect state even when JavaScript runs on one thread. The test needs to represent the interleaving that matters, whether it occurs between Promises, callbacks, requests, messages, or transactions.

Should I cancel the old request or ignore its response?

It depends on the contract. Cancellation can save work when the dependency honors the signal, but it does not necessarily undo an effect that already started. Ignoring the response protects local state. Test the remote effect separately and document what cancellation really means.

Conclusion

A race condition stops being an accident of timing when the test can choose the order in which operations finish. Create deferred Promises, force the sequence that threatens the invariant, and make the case fail when the guard disappears.

Then take the same claim to the real boundary. A database, queue, browser, or remote service can add rules that an in-memory fixture cannot see. The result is an honest two-part suite: one test explains the logic, and another checks the environment that can break it.

Sources consulted