Development How-to

Debug an async race by making it fail on demand: deferred promises, one failing test, then the fix

A race that shows up once a week in production can be reproduced in ten lines. Do that before the agent proposes a single change.

Two glowing spheres on a single rail swap order through a tear of horizontal glitch lines.

The bug report says the search box sometimes shows results for the wrong query. Not always. Not on the developer’s machine. Someone types “ab”, pauses, types “c”, and the list that settles on screen belongs to “ab”. Paste that report into an agent and a typical answer is a plausible patch (a debounce, a loading flag, a useEffect cleanup) that nobody can show fixes anything, because nobody has shown the bug.

The fix for that is old and boring: make the failure happen on demand, in a test, before anything is changed. A race condition is the case where that feels hardest and pays the most. This piece builds the repro in about ten lines of TypeScript with Vitest, shows the failing output, and then lets an agent (or you) fix the code with the test as the judge. The technique, the prompt and the check work with any coding agent that can read files and run a shell.

Why races resist “just reproduce it”

An out-of-order response needs two requests in flight and the older one to finish last. On a fast network that’s rare. Add a real setTimeout or a real server to a test and it passes on most runs and fails on a few, which is the flaky test you were trying to avoid.

The trick is to stop letting time decide. Replace the network with promises you resolve by hand, in the order that breaks the code. The race stops being a matter of luck and becomes a script: request A starts, request B starts, B resolves, then A resolves. No timers, no sleeping, no retries.

The code with the bug

Here’s the whole module. It’s the shape of a thousand search boxes, typeahead fields and autocomplete handlers:

// src/search.ts
export type Fetcher = (query: string, signal?: AbortSignal) => Promise<string[]>;

export function createSearch(fetchResults: Fetcher, render: (items: string[]) => void) {
  return async function search(query: string): Promise<void> {
    const items = await fetchResults(query);
    render(items);
  };
}

Nothing here looks wrong, and that’s the point. Each call awaits its own response and renders it. The bug is in what the code doesn’t say: nothing relates one call to the next, so whichever response arrives last wins, whether or not it’s the newest.

The fetcher and the renderer are passed in. That’s a seam, and it’s what makes the repro cheap. If your real code calls fetch directly, the first job is to put a seam like this in, which is a small, behaviour-preserving change.

Step 1: a deferred promise

A deferred is a promise whose resolve function you hold. It’s six lines:

function deferred<T>() {
  let resolve!: (value: T) => void;
  const promise = new Promise<T>((r) => (resolve = r));
  return { promise, resolve };
}

Now the test controls when each request finishes. The fetcher below hands out one deferred per query and remembers them, so the test can settle them in any order.

Step 2: the failing test

// src/search.test.ts
import { expect, it } from "vitest";
import { createSearch, type Fetcher } from "./search";

it("shows results for the latest query even when responses arrive out of order", async () => {
  const pending = new Map<string, ReturnType<typeof deferred<string[]>>>();
  const fetcher: Fetcher = (q) => {
    const d = deferred<string[]>();
    pending.set(q, d);
    return d.promise;
  };
  const rendered: string[][] = [];
  const search = createSearch(fetcher, (items) => rendered.push(items));

  const first = search("ab");
  const second = search("abc");

  pending.get("abc")!.resolve(["abc-result"]); // the newer answer lands first
  await second;
  pending.get("ab")!.resolve(["ab-result"]); // the stale answer lands last
  await first;

  expect(rendered.at(-1)).toEqual(["abc-result"]);
});

The assertion is on the last thing rendered, because that’s what the user is left looking at. Run it with npx vitest run and it fails every time:

- Expected
+ Received

  [
-   "abc-result",
+   "ab-result",
  ]

 ❯ src/search.test.ts:28:27
     26|   await first;
     27|
     28|   expect(rendered.at(-1)).toEqual(["abc-result"]);
       |                           ^

 Test Files  1 failed (1)
      Tests  1 failed (1)

That’s the production bug, reduced to a deterministic failure. It fails for the right reason (the stale response overwrote the fresh one), and there’s no timing in it, so it will fail the same way on a laptop, in CI and on the agent’s machine.

Step 3: hand it to the agent with a narrow brief

The prompt matters here, because an agent given the bug report will happily rewrite the component. Give it the failing test and a boundary:

src/search.test.ts has a failing test that reproduces a race: a stale response
overwrites a newer one. Change src/search.ts so that test passes. Rules:
- Don't edit the existing test or weaken its assertion.
- Don't add timers, sleeps or debounce. The bug is ordering, not speed.
- When a newer query starts, an older query must never render, even if it
  resolves later.
- Pass an AbortSignal to the fetcher so superseded requests can be cancelled.
- Run `npx vitest run` and show me the output.

Each rule closes off a familiar bad fix. A debounce shrinks the window but doesn’t close it: two responses can still cross. Editing the test to expect ab-result turns the suite green and the bug stays. Asking for an AbortSignal points at the fix that also saves the network work.

Step 4: the fix, and why it needs two guards

A correct version looks like this:

// src/search.ts
export type Fetcher = (query: string, signal?: AbortSignal) => Promise<string[]>;

export function createSearch(fetchResults: Fetcher, render: (items: string[]) => void) {
  let current: AbortController | undefined;

  return async function search(query: string): Promise<void> {
    current?.abort();
    const controller = new AbortController();
    current = controller;

    try {
      const items = await fetchResults(query, controller.signal);
      if (controller.signal.aborted) return; // a newer query took over while this one was in flight
      render(items);
    } catch (err) {
      if (controller.signal.aborted) return; // abort rejections from superseded queries are expected
      throw err;
    }
  };
}

There are two checks on signal.aborted, and both earn their place. A real fetch that’s cancelled through its signal rejects with a DOMException named AbortError (the Fetch and DOM specifications define this; MDN documents abort() and the signal), so the catch has to swallow the rejection of a superseded request without hiding a genuine failure of the current one. The check after the await covers fetchers that ignore the signal: a wrapper, a cached promise, a client library that doesn’t forward it. Those resolve normally, and without the check the stale data renders again.

Run the suite and the first test passes. Don’t stop there, because the original test only proves the ordering case.

Step 5: pin the parts the first test doesn’t

The fix added two behaviours that nothing yet checks: superseded requests get cancelled, and real errors still surface. Add a test for each:

it("passes an abort signal and swallows the abort of a superseded request", async () => {
  const signals: AbortSignal[] = [];
  const fetcher: Fetcher = (_q, signal) =>
    new Promise((_resolve, reject) => {
      signals.push(signal!);
      signal!.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
    });
  const search = createSearch(fetcher, () => {});

  const first = search("a");
  void search("ab");

  await expect(first).resolves.toBeUndefined();
  expect(signals[0].aborted).toBe(true);
  expect(signals[1].aborted).toBe(false);
});

it("still reports a real failure of the current request", async () => {
  const search = createSearch(() => Promise.reject(new Error("503")), () => {});
  await expect(search("x")).rejects.toThrow("503");
});

All three pass:

 RUN  v5.1.0

 Test Files  1 passed (1)
      Tests  3 passed (3)

The second test guards against the overcorrection agents like to make: wrapping everything in a catch that swallows errors so the abort noise goes away. With that bug, a 503 on the latest query disappears and the user stares at a blank list.

Check that the test would catch a regression

A test you’ve only seen pass hasn’t proved much. Delete the line that follows the await (the if (controller.signal.aborted) return; guard before render) and run the suite again:

 FAIL  src/search.test.ts > shows results for the latest query even when responses arrive out of order
      Tests  1 failed | 2 passed (3)

The ordering test fails again, because the fetcher in that test ignores the signal, which is exactly the case the guard exists for. That’s a hand-made mutation, and it takes thirty seconds. If you already run StrykerJS, this is the kind of survivor it would report. Restore the line before moving on.

Make it yours

The same shape works well beyond search boxes:

  • Form double-submits: two deferreds for two submit() calls, resolved in reverse, assert only one side effect.
  • Cache fill races: two callers ask for the same missing key, resolve the loads in both orders, assert one entry and no overwrite by the older load.
  • Token refresh: three requests fail with 401 at once, assert one refresh call and three retries.
  • C# services: the equivalent seam is a TaskCompletionSource<T>. Hold the source, complete the tasks in the order that breaks the code, and assert on the final state in an xUnit test.

For React components, keep the repro at the module level, as here, and test the component only for wiring. Races are easier to pin in plain functions than through a rendered tree and a mocked fetch.

Where this goes wrong

Deferred promises model the ordering you thought of. They won’t find an interleaving you didn’t, so for state machines with many moving parts, add a property-based test that shuffles the resolve order and checks an invariant, and treat the hand-written case as the regression test for the bug you’ve seen.

The technique also assumes a seam. In code that calls a global fetch or a singleton client in the middle of a function, an agent will sometimes “fix” the race without a test because the test is awkward to write. Make it write the seam first and review that diff on its own.

Don’t bother when the race is in the browser’s rendering or the server’s database isolation rather than in your promise ordering. A deferred in a unit test can’t reproduce a lost update between two transactions, and a test that pretends otherwise gives false comfort.

First 15 minutes

Pick the last intermittent bug in your tracker that involves two async calls. Write the interface the code calls (the fetcher), swap in a deferred, and make the test fail in the order the report describes. Commit the failing test on its own, then give the agent the prompt above with your own file names.