Ch. 22 · Software Testing

Async Tests and Controlled Completion Order

Replace sleeps with deferred promises so an async test decides which response lands first, catching stale-result races on every run.

~7 min readintermediateupdated Oct 11, 2026

Most async bugs are ordering bugs. Two requests go out, and the answers come back in an order nobody planned for. A timer fires after the component it belongs to has gone. A save completes after the user has already moved to another page. The code is fine when things finish in the “usual” order and wrong otherwise.

A test can only catch that kind of bug if it decides the order itself. Tests that wait with setTimeout or sleep(100) and hope the work is done by then don’t decide anything. They inherit whatever order the machine produces that day, which is why they pass on a laptop and fail on a loaded CI runner, or pass every time and never exercise the bad order at all.

Why sleeping in a test doesn’t help

A sleep has two failure modes, and you get one or the other depending on the number you pick. Too short, and the test fails when the machine is slow. Too long, and every run of the suite pays that delay, so people bump the number up after each flaky failure until the suite takes minutes to do seconds of work.

Worse, a sleep doesn’t test the race you care about. If your fake server answers every request after 10 ms, responses always arrive in the order they were sent. The bug where an old response lands after a new one is never exercised. You’d need to give requests different delays, and then you’re writing a scheduling puzzle in milliseconds and hoping the event loop solves it the way you expect.

The alternative is to stop simulating time and control completion directly. Hand the code a promise, keep the resolve function, and call it when the test wants that piece of work to finish.

A search box that shows the wrong results

Here’s a search-as-you-type model. Every keystroke calls search(query), which fetches results and stores them.

export function createSearch(fetchResults) {
  const state = { query: '', results: [] };
  return {
    state,
    async search(query) {
      state.query = query;
      state.results = await fetchResults(query);
    },
  };
}
JavaScript

Type re, then react. Two requests are in flight. If the backend is slower for the shorter, broader query (plausible: more matches to rank), the react results arrive first and get stored, then the re results arrive and overwrite them. The box says react and shows results for re. Nothing throws. A user just sees the wrong list.

Deciding the order with deferred promises

Promise.withResolvers() (Node 22, all current browsers) returns a promise together with its resolve and reject functions, so the test can finish the work whenever it likes. Wrap it in a fake fetch that remembers one deferred per query:

function controllableFetch() {
  const pending = new Map();
  const fetchResults = (query) => {
    const deferred = Promise.withResolvers();
    pending.set(query, deferred);
    return deferred.promise;
  };
  return { fetchResults, pending };
}

test('a slow earlier response cannot overwrite a newer one', async () => {
  const { fetchResults, pending } = controllableFetch();
  const search = createSearch(fetchResults);

  const first = search.search('re');
  const second = search.search('react');
  pending.get('react').resolve(['React docs']);
  await second;
  pending.get('re').resolve(['regex', 'Redis']);
  await first;

  assert.equal(search.state.query, 'react');
  assert.deepEqual(search.state.results, ['React docs']);
});
JavaScript

There’s no timer anywhere. The test starts both searches, finishes the newer one, waits for it, then finishes the older one. The order is written in the test, so it’s the same on every machine and every run. Against the naive version:

✖ naive: a slow earlier response cannot overwrite a newer one (1.173625ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected

    [
  +   'regex',
  +   'Redis'
  -   'React docs'
    ]
Text

The fix is to remember which search is the latest and drop any response that belongs to an older one:

export function createSafeSearch(fetchResults) {
  const state = { query: '', results: [] };
  let latest = 0;
  return {
    state,
    async search(query) {
      const ticket = ++latest;
      state.query = query;
      const results = await fetchResults(query);
      if (ticket !== latest) return; // a newer search started while we waited
      state.results = results;
    },
  };
}
JavaScript
✔ safe: a slow earlier response cannot overwrite a newer one (0.173083ms)
Text

Aborting the older request with an AbortController is a good addition, since it saves bandwidth, but it isn’t a replacement for the ticket check. A response can already be on its way when you call abort(), and a fetch wrapper that ignores the signal would bring the bug straight back. The deferred-promise test covers both designs because it only looks at the final state.

Testing every order, not just the one you thought of

With three keystrokes there are six possible arrival orders, and you don’t have to pick the scary one by hand. Generate them all:

const queries = ['r', 're', 'react'];
const permutations = (xs) => xs.length <= 1 ? [xs]
  : xs.flatMap((x, i) => permutations([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));

for (const order of permutations(queries)) {
  test(`responses arriving as ${order.join(' > ')} leave the newest results`, async () => {
    const pending = new Map();
    const search = createSafeSearch((q) => {
      const d = Promise.withResolvers();
      pending.set(q, d);
      return d.promise;
    });
    const calls = queries.map((q) => search.search(q));

    for (const q of order) pending.get(q).resolve([`results for ${q}`]);
    await Promise.all(calls);

    assert.deepEqual(search.state.results, ['results for react']);
  });
}
JavaScript

Resolving in a loop works because each await continuation is queued in the order its promise was resolved. Run against the naive version, four of the six orders fail (every order where react isn’t last); against the safe version all six pass. That’s a test you can trust to say “there is no bad order”, rather than “the order I happened to think of is fine”.

Two ways an async test lies to you

Controlling the order solves flakiness. These two problems are about tests that report the wrong thing entirely, and both show up in code review constantly.

The assertion that runs after the test has ended. If you start async work and don’t await or return it, the test function returns, the runner records a pass, and your assertion runs later:

test('forgot to await the search', () => {
  const search = createSearch(async () => ['regex']);
  search.search('re').then(() => {
    assert.deepEqual(search.state.results, ['something else']);
  });
});
JavaScript

The assertion is wrong, yet Node’s runner reports ✔ forgot to await the search, a summary of pass 1 and fail 0, and only an informational line explaining that the test “generated asynchronous activity after the test ended” and that the error “would have caused the test to fail, but instead triggered an unhandledRejection event.” Other runners have their own versions of this, and the safe assumption is that an un-awaited assertion isn’t checked at all. The rule is simple: an async test is an async function that awaits everything it starts. A lint rule such as @typescript-eslint/no-floating-promises catches the rest.

The promise nobody resolves. Deferred promises put you in charge, which also means you can forget to resolve one. When nothing else keeps the process alive, node:test notices and cancels the test:

✖ waits on a response nobody resolves (2.424041ms)
  'Promise resolution is still pending but the event loop has already resolved'
Text

If something does keep the event loop busy, like an open socket or an interval, the test just waits. Node’s default per-test timeout is infinity. Set one explicitly with test(name, { timeout: 200 }, fn) and you get 'test timed out after 200ms' instead of a CI job that sits there until the pipeline’s own limit kills it. When the leaked handle also keeps the process from exiting after the timeout, node --test --test-force-exit ends the run anyway, though the real fix is to close whatever you left open.

Interview practice

You’re reviewing a test for an autosave feature. The editor saves 500 ms after the last keystroke; if a save is still in flight when another begins, the older save’s response must not overwrite the “last saved at” label. The test does type('a'); await sleep(600); type('b'); await sleep(600); expect(label).toBe(savedAtFor('b')). It passes. What’s wrong with it, and how would you test the requirement?

Answer

It has both sleep problems. It takes 1.2 seconds of real time for one assertion, and it can fail on a slow runner where the save hasn’t finished within 600 ms. More importantly, it never creates the situation the requirement is about. With 600 ms gaps, the first save almost certainly completes before the second starts, so the overlapping-saves path never runs. The test passes against an implementation with the bug.

I’d split the two concerns. The 500 ms debounce is a clock problem, so I’d use fake timers (mock.timers in node:test, or an injected clock) and advance time to show that typing within 500 ms produces one save and pausing for 500 ms produces exactly one. The overlap is an ordering problem, so I’d give the editor a save function that returns a deferred promise. The test types a, advances the clock to trigger save one, types b, advances again to trigger save two, resolves save two, then resolves save one, and asserts the label still shows save two’s time. I’d also add the reverse order as a control and give the test a timeout so a forgotten resolve fails fast.

Further reading

More in Software Testing

read ✓Software Testing · mid

Flaky Tests and Root-Cause Evidence

Diagnose flaky tests with evidence: measure the failure rate, compare a test alone and in the suite, and replace sleeps that fail 23 runs in 100.

~7 min readread →
read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
read ✓Software Testing · hard

Parallel Test Isolation

Reproduce a shared temp file, a fixed port and module-level state breaking parallel node:test runs, then give each test its own resources.

~7 min readread →
esc