Async code should be structured so it cannot race
Async bugs feel slippery because the code is not the only thing moving. The world keeps changing while your promise is in flight.
That is why a function can look perfectly reasonable in isolation and still be wrong in the system around it.
Auto-save is a clean example. Here is the naive version:
class AutoSave {
async save(document: Document): Promise<void> {
const serialized = serialize(document);
await uploadToServer(serialized);
}
}
Nothing in that method is strange. The bug appears when you call it twice.
autoSave.save(docA);
// user keeps typing
autoSave.save(docB);
If the first request finishes after the second one, stale data can overwrite fresh data. The API allowed overlapping work and said nothing about what should happen.
The first improvement is to track whether a result is stale.
function createAsyncSequencer() {
let currentGeneration = 0;
return {
start(): { generation: number; isCurrent: () => boolean } {
const generation = ++currentGeneration;
return {
generation,
isCurrent: () => generation === currentGeneration,
};
},
};
}
const saveSequencer = createAsyncSequencer();
async function autoSave(document: Document): Promise<{ saved: boolean }> {
const guard = saveSequencer.start();
const serialized = serialize(document);
await uploadToServer(serialized);
if (!guard.isCurrent()) {
return { saved: false };
}
return { saved: true };
}
That is better than blindness. At least the caller can tell that an older operation lost the race.
But it is not the strongest fix. The stale upload still went out. You only discovered the problem after the bad timing had already happened.
The stronger design is to stop overlapping saves from running at all.
function createSerialQueue() {
let pending: Promise<void> = Promise.resolve();
return {
enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = pending.then(fn);
pending = result.then(() => {}, () => {});
return result;
},
};
}
const saveQueue = createSerialQueue();
function autoSave(document: Document): Promise<void> {
return saveQueue.enqueue(async () => {
const serialized = serialize(document);
await uploadToServer(serialized);
});
}
Now the ordering guarantee lives inside the API. The caller can trigger autoSave() twice and still not create a race, because the queue serializes the work.
That is the difference between detection and prevention.
Detection says, “we noticed the timing bug after it happened.”
Prevention says, “the structure made that timing pattern impossible.”
Not every async workflow needs a serial queue. Sometimes the right model is latest-only cancellation. Sometimes it is keyed deduplication. Sometimes it is a version check enforced on the server. The point is not that one helper solves all concurrency. The point is that async APIs need an explicit model for overlapping work.
The dangerous version is the one that acts like concurrency is somebody else’s problem.
Here is another way to see it. In the naive design, the caller has to know hidden rules:
Do not start another save too soon. Do not ignore which save finishes last. Do not forget that the document may have changed while this request was in flight.
That is a lot to ask from every call site.
In the queued design, the caller only knows one rule: call autoSave(document). The concurrency policy is owned by the structure that needs it.
That is almost always where these rules belong.
Async code gets safer when the timing model stops being a social agreement and becomes part of the API. You do not want a codebase full of comments that amount to “be careful when calling this.” You want designs that absorb the carelessness.
The best async API is not the one with the best warning comment. It is the one that makes the race impossible, or at least makes the valid concurrency patterns the only easy ones to express.