In this article
Make an asynchronous bug reproducible before asking AI to repair it. This illustrative TypeScript search example controls which request finishes first, exposes a stale response, and tests a request-generation guard against explicit expectations. The tests use promises as gates rather than waiting for a lucky network delay.

Define which request owns the visible result
A user types ca and then cat into a search box. Both requests start, but the response for cat arrives first. The screen briefly shows the right results before the older response replaces them with car. This is an invented exercise, not a production incident or an employer interview question. We want a small reproduction that makes this sequence happen on demand.
Choose the contract before looking at a proposed patch: once a newer request starts, no older request may render results, even while the newer request is still pending. A successful newest request renders once. A failed newest request rejects to its caller and does not make an older request eligible again. Already displayed results may remain until a new successful result replaces them; this example does not implement loading or error UI.
That last distinction matters. Showing whichever request succeeds last would be a different product rule. So would clearing results immediately on each keystroke. Write these choices down now, because an assistant can produce plausible implementations of all three behaviors.
Find the suspension point in the broken implementation
The controller below takes a loader and a rendering callback. Both invocations share the same visible destination, but each waits independently. Await suspends the surrounding async function; it does not pause the entire application. After the awaited promise settles, that invocation can continue. The language behavior permits our two searches to resume in a different order from their starts. MDN: await and asynchronous control flow
There is no need for two JavaScript statements to execute at the same instant. The defect is a missing ownership rule across an asynchronous boundary. For the example, keep the loader injectable and the renderer synchronous. Removing the browser, network, and component lifecycle makes the disagreement easier to inspect without pretending those integration concerns have been tested.
type Load = (query: string) => Promise<string[]>;
type Search = (query: string) => Promise<void>;
type SearchFactory = (
load: Load,
render: (items: string[]) => void,
) => Search;
const createSearchBroken: SearchFactory = (load, render) => {
return async (query) => {
const items = await load(query);
render(items);
};
};Force the failing order with controlled promises
A deferred promise gives the test control over when a load finishes. Start both searches, resolve the newer load, await its controller invocation, and only then resolve the older load. Each await marks completion of the operation we actually care about. A delay such as waiting fifty milliseconds would instead guess when another task had finished.
Combine the preceding controller and this block in race.test.mts. With Node.js 24, run node --test race.test.mts. The mts extension selects ES modules, and Node can strip this example's erasable type syntax without installing another package; that execution does not perform type checking. Node.js 24: TypeScript execution
The test deliberately fails: expected frames are only cat, while the broken controller records cat followed by car. Node treats a rejected Promise returned by an async test as a failure, so keep the assertions inside the awaited test body. Node.js 24: test runner
The expected frames come from the written ownership contract. They are not calculated with the controller code. Recording every render also catches a brief stale update that a final-state assertion could miss. The exact completion sequence is the evidence to give an assistant, together with the smallest relevant implementation.
import assert from 'node:assert/strict';
import test from 'node:test';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
async function newerFinishesFirst(make: SearchFactory) {
const older = deferred<string[]>();
const newer = deferred<string[]>();
const frames: string[][] = [];
const search = make(
(query) => query === 'ca' ? older.promise : newer.promise,
(items) => frames.push(items),
);
const first = search('ca');
const second = search('cat');
newer.resolve(['cat']);
await second;
older.resolve(['car']);
await first;
return frames;
}
test('only the newest request may render', async () => {
assert.deepStrictEqual(
await newerFinishesFirst(createSearchBroken),
[['cat']],
);
});Review a request-generation repair at the write boundary
A focused prompt is enough: the supplied test fails because an older request renders after a newer request starts; preserve the loader signature and implement the stated ownership contract. Ask the assistant to explain which write is guarded and what remains outside the patch. Reject suggestions that merely add a timeout or assume that the network returns responses in request order.
One repair assigns each invocation an increasing number before awaiting the loader. The continuation may render only if its number still matches the newest invocation. Keep the comparison immediately beside the synchronous write. Inserting another await between the comparison and render would create another interval in which ownership could change. Await still suspends execution even for an already fulfilled promise. MDN: await and asynchronous control flow
Add this controller before the test, then change the reproduction to call createSearch instead of createSearchBroken. Leave the expected frames unchanged. The closure belongs to one search controller: constructing a new controller for every request would reset its counter and break the protection. Query text is also an inadequate identity because two requests for cat can still return different data.
const createSearch: SearchFactory = (load, render) => {
let latestRequest = 0;
return async (query) => {
const request = ++latestRequest;
const items = await load(query);
if (request === latestRequest) render(items);
};
};Challenge the repair with another order and a rejection
Keep the original failing order as a regression, then reverse it. If the older response arrives while the newer request is pending, the older response must still produce no render. This catches a weaker repair that suppresses stale data only after the newer request has completed. Check the intermediate frames before releasing the second promise.
Also reject the newest request and finish the older one afterward. Attach the rejection assertion before rejecting the promise, so the test deliberately observes the error. Our contract leaves prior visible data alone; it does not roll back request ownership. The caller remains responsible for presenting an error. Strict deep equality compares the recorded nested arrays against literal expected values. Node.js 24: strict assertions
Append these tests to the repaired file. Beyond these cases, check a single request, two requests with identical query strings, and separate controller instances. Distinct instances should not invalidate each other. During verification for this article, the broken reproduction failed as expected and the repaired implementation passed all six of these cases. Temporarily removing the generation check made four cases fail, confirming that the suite detects that regression.
test('older completion is ignored while newer is pending', async () => {
const older = deferred<string[]>();
const newer = deferred<string[]>();
const frames: string[][] = [];
const search = createSearch(
(query) => query === 'ca' ? older.promise : newer.promise,
(items) => frames.push(items),
);
const first = search('ca');
const second = search('cat');
older.resolve(['car']);
await first;
assert.deepStrictEqual(frames, []);
newer.resolve(['cat']);
await second;
assert.deepStrictEqual(frames, [['cat']]);
});
test('a failed newest request does not revive an older one', async () => {
const older = deferred<string[]>();
const newer = deferred<string[]>();
const frames: string[][] = [];
const search = createSearch(
(query) => query === 'ca' ? older.promise : newer.promise,
(items) => frames.push(items),
);
const first = search('ca');
const second = search('cat');
const rejected = assert.rejects(second, { message: 'offline' });
newer.reject(new Error('offline'));
await rejected;
older.resolve(['car']);
await first;
assert.deepStrictEqual(frames, []);
});Keep the repair within its actual guarantees
Ignoring a stale result does not cancel its request or undo a remote side effect. This controller is suitable for the stated read-and-render exercise; applying the same idea to payments or database writes would require a different correctness argument. Cancellation may reduce wasted work, but whether it prevents a particular write depends on where the result is consumed and how cancellation is handled.
A real component may also change loading flags, errors, cached records, or analytics when a request completes. Review those writes separately and decide which require the same ownership rule. Add lifecycle tests for unmounting or switching accounts if those events exist in the application. These controlled tests verify selected schedules at the controller boundary; they do not enumerate every interleaving or establish network and UI integration correctness.
When reviewing the AI patch, explain the observed failure, the contract, the guarded write, and the tested counterexamples. A passing demonstration is useful evidence. It is not permission to describe an untested production system as race-free.
Common questions about testing asynchronous races
Why not run the original test hundreds of times? Repetition can reveal intermittent failures, but it does not tell this test which request to finish first. Controlled promises encode the schedule directly and make the failure easy to reproduce and explain.
Will adding await to the caller fix it? Only if the caller deliberately serializes all searches. That changes when later requests can start. Our repair permits overlapping loads while restricting which completion may update visible results.
Can a debounce replace this guard? A debounce changes when requests begin. Once two requests overlap, delayed older responses still need an ownership policy. Treat request reduction and result correctness as separate requirements.
What should I ask AI after the first test passes? Ask it to predict the older-first, repeated-query, and newest-rejection cases before running them. Compare those predictions with literal expectations, then inspect any other state the actual application writes after awaiting a response.
Sources & editorial notes
This article was authored with AI using primary-source research and an automated technical review. It has not been reviewed by a human. Code examples are illustrative; run and verify them in your own environment.
- MDN: await and asynchronous control flow
Explains await suspension and continuation scheduling; supports the asynchronous control-flow discussion.
- Node.js 24: test runner
Documents Promise-returning tests and failure behavior used by the executable regression harness.
- Node.js 24: strict assertions
Documents strict structural comparisons and rejection assertions used to check the observed results.
- Node.js 24: TypeScript execution
Documents native execution of erasable TypeScript and the mts module format; distinguishes execution from type checking.
Put the reading into practice
Work through an existing repository, review AI suggestions, and test the decisions behind your changes.
Explore practice problems