Async Patterns and Concurrency
Running work in parallel versus in sequence, all versus allSettled versus race, cancelling with an abort signal, and the awaited loop that made everything ten times slower.
Running work in parallel versus in sequence, all versus allSettled versus race, cancelling with an abort signal, and the awaited loop that made everything ten times slower.
The import job fetches eight thousand records. Written as a loop
with an await inside, it takes forty minutes. Rewritten with
Promise.all, it opens eight thousand connections at once, gets
rate-limited, and fails — so somebody puts the loop back.
Neither extreme is right. By the end of this lesson you will bound how much runs at once, cancel work nobody is waiting for, handle partial failure without losing the successes, and recognise the shapes that turn concurrent work into sequential work by accident.
for (const id of ids) {
results.push(await fetchRecord(id)); // one at a time
}
const results = await Promise.all(ids.map(fetchRecord)); // all at onceSafe, and forty minutes.
Each request waits for the last. Nothing can overwhelm anything, and nothing finishes either.
Fast, and eight thousand connections.
Promise.all starts every request immediately. Your memory,
your file descriptors and the other service all find out
together.
Fast, and ten connections.
Ten workers, each taking the next item when it finishes. The only version you can predict the behaviour of.
So the answer is a limit:
async function mapWithLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
async function worker(): Promise<void> {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index]!);
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker),
);
return results;
}const records = await mapWithLimit(ids, 10, fetchRecord);Ten workers, each taking the next item when it finishes. Order is preserved because each writes to its own index. Memory is bounded, the service sees ten connections, and forty minutes becomes four.
Libraries provide this — p-map, p-limit — and the
implementation is worth reading once because the shape appears
everywhere.
Bad — independent work awaited in sequence.
async function loadDashboard(userId: string): Promise<Dashboard> {
const user = await fetchUser(userId);
const photos = await fetchPhotos(userId);
const settings = await fetchSettings(userId);
return { user, photos, settings };
}Good — started together, awaited together.
None of the three needs the others, and the first version takes their sum — three hundred-millisecond calls become nine hundred milliseconds of a page load, for nothing. The second takes the slowest.
This is the most common performance problem in async code, and
it is invisible in review because each line looks correct. The
tell is an await whose result the next line does not use.
The related shape from the foundations course, worth repeating because it is a bug rather than a slowdown:
map does not wait. It returns an array of promises, and the
code after it runs immediately.
Promise.all rejects as soon as anything does, and you lose the
results that succeeded:
Promise.allSettled never rejects:
Each result is a discriminated union narrowed by status — the
pattern from the unions lesson, in the standard library.
Which to use is a product question, not a technical one.
All or nothing.
A transaction, a set of writes that must agree. Nineteen successes and one failure is a state nobody wants to be left in, so the whole thing rejects.
Best effort.
A dashboard, a batch import, a page of independent widgets. Nineteen out of twenty is genuinely useful, and the one failure is a log line rather than an outage.
The third, Promise.any, resolves on the first success, which
suits querying several mirrors for whichever answers first.
Note that Promise.all rejecting does not cancel the other
work. Those requests continue, and their results are discarded.
Stopping them needs the next section.
A promise cannot be cancelled. What you cancel is the operation
underneath, with an AbortSignal:
The two places this matters constantly.
A timeout, which nothing has by default:
Without one, a service that accepts the connection and never answers holds your request forever, with no error and no log line.
Work that is no longer wanted — a search-as-you-type where the user typed another character, a component that unmounted:
Without that, responses arrive out of order and an older, slower one overwrites a newer one — results for "lis" replacing results for "lisbon".
Passing the signal through your own functions is worth doing from the start:
Retrofitting cancellation into a call stack that does not thread it is considerably more work than adding the parameter now.
Combine several with AbortSignal.any([...]) — a timeout and a
user cancellation together.
Three details carry the weight, and they are the same ones from the AI course applied here.
isRetryable. A 400 will fail identically five times; a
503 may not. Retrying what cannot succeed turns one fast
failure into five slow ones.
Exponential backoff. Doubling the wait gives a struggling service room to recover instead of adding to the load.
Jitter — the random component. Without it, a hundred clients that failed together retry together and fail together again.
And the rule that is easy to miss: do not retry a request that
creates something unless the server supports an idempotency
key. A POST that timed out may have succeeded with only the
response lost, so retrying creates a second order.
Not everything should be parallelised. Two cases:
Order matters. Applying a list of updates where each depends
on the last is a for...of with await, deliberately.
A resource allows one at a time. A rate limit of one request per second, a lock, a single writer.
The mistake to avoid is a queue built from a chain:
That works and the catch is essential — without it, one
failure poisons the chain and everything queued afterwards
rejects. It is the kind of detail that makes a hand-rolled queue
worth replacing with a library once it matters.
You can now run work at a rate a service can take, keep the
successes when some of it fails, stop what nobody is waiting
for, and spot the await that quietly made everything
sequential. The habit worth adopting today is threading an
AbortSignal through new async functions — it costs one
optional parameter and cannot be retrofitted cheaply.
Next is Module Resolution, ESM, and CJS, which is the subject behind the most confusing errors in this ecosystem: an import the checker accepts and the runtime rejects.
Before you move on, find three sequential awaits in your code
where none uses the previous result, and combine them with
Promise.all. Time the page or the endpoint before and after.
It is usually the largest single improvement available for the
least work.
THREE SHAPES
for (const x of xs) await f(x) sequential - slow, safe
await Promise.all(xs.map(f)) all at once - unbounded
await mapWithLimit(xs, 10, f) bounded <- usually this
N workers pulling the next index; order preserved, memory bounded
p-map / p-limit do this for you
THE SEQUENTIAL AWAIT
const a = await f(); const b = await g(); sum of both
const [a, b] = await Promise.all([f(), g()]); the slower one
the tell: an await whose result the NEXT LINE does not use
xs.map(async x => await f(x)) waits for nothing at all
PARTIAL FAILURE
Promise.all rejects on the first failure; successes lost
and the other work KEEPS RUNNING
Promise.allSettled never rejects; narrow on .status
Promise.any the first SUCCESS
all-or-nothing -> all; best effort -> allSettled
CANCELLATION
a promise cannot be cancelled; the operation under it can
new AbortController(); controller.abort()
AbortSignal.timeout(10_000) nothing times out by default
AbortSignal.any([a, b]) combine
thread { signal } through your own functions from the start
signal?.throwIfAborted() inside your own loops
without it: out-of-order responses overwrite newer results
RETRIES
isRetryable(error) a 400 fails five times
2 ** attempt backoff give the service room
+ random jitter or every client retries in lockstep
never retry a create without an idempotency key
DELIBERATELY SEQUENTIAL
order matters; one-at-a-time resources
a promise-chain queue needs a .catch, or one failure poisons itasync function loadDashboard(userId: string): Promise<Dashboard> {
const [user, photos, settings] = await Promise.all([
fetchUser(userId),
fetchPhotos(userId),
fetchSettings(userId),
]);
return { user, photos, settings };
}items.map(async (item) => await save(item)); // waits for nothingconst results = await Promise.all(ids.map(fetchRecord));
// one 404 and you have nothingconst settled = await Promise.allSettled(ids.map(fetchRecord));
const records = settled
.filter((r) => r.status === "fulfilled")
.map((r) => r.value);
const failures = settled
.filter((r) => r.status === "rejected")
.map((r) => r.reason);
log.warn(`${failures.length} of ${ids.length} failed`);const controller = new AbortController();
const response = await fetch(url, { signal: controller.signal });
controller.abort(); // the fetch rejects with an AbortErrorconst response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});let controller: AbortController | undefined;
async function search(query: string): Promise<Result[]> {
controller?.abort(); // cancel the previous
controller = new AbortController();
const response = await fetch(`/search?q=${query}`, {
signal: controller.signal,
});
return response.json();
}async function loadPhotos(
ids: string[],
options: { signal?: AbortSignal } = {},
): Promise<Photo[]> {
return mapWithLimit(ids, 10, (id) => fetchPhoto(id, options));
}for (const item of items) {
signal?.throwIfAborted();
await process(item);
}async function withRetry<T>(
fn: () => Promise<T>,
{ attempts = 3, signal }: { attempts?: number; signal?: AbortSignal } = {},
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt >= attempts || !isRetryable(error)) throw error;
const delay = 2 ** (attempt - 1) * 100 + Math.random() * 100;
await sleep(delay, signal);
}
}
}let queue: Promise<unknown> = Promise.resolve();
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = queue.then(fn);
queue = result.catch(() => {}); // note the catch
return result;
}