Async / Await
A dashboard endpoint loads three things for the signed-in customer: their profile, their recent orders, and a row of recommendations. Each comes from a different service, and none of them is slow — 200 ms for the profile, 400 ms for the orders, 300 ms for the recommendations. The endpoint takes 900 ms. Nobody wrote a slow query. Each line of the handler waits for its answer before the next line asks its question, although none of the three needs another’s answer. Rewritten to ask all three at once, the same endpoint answers in 400 ms.
The mistake is one that async and await make easy to write. They let
asynchronous code read like ordinary code, top to bottom, with try and catch
where you expect them. That resemblance is the appeal, and it has a price: code
that reads top to bottom also waits top to bottom, unless you arrange otherwise.
Two keywords over a promise
An async function always returns a promise, and it returns it at once. Calling
one runs its body synchronously up to the first await, then hands the caller a
pending promise. Whatever the body later returns fulfils that promise; whatever
it throws rejects it. Even async function one() { return 1; } gives back a
promise of 1 rather than 1, which is why one() + 1 is a type error in
TypeScript and the string [object Promise]1 in plain JavaScript.
await takes a promise and suspends the function it is written in until that
promise settles: if it fulfils, the expression is its value; if it rejects, the
reason is thrown at that line. Only that one function is suspended. The rest of
it resumes later as a microtask, and the thread is free for other work in
between. No new machinery is involved: const user = await getUser(id) is a
.then on the promise getUser(id) returned, with the rest of the function as
its callback.
One distinction carries the rest of this page. Calling an async function
starts the work; await only decides when you stop to collect the result.
getUser(id) sends its request the moment it runs. Put await in front and
the function goes no further until the answer is back — right if the next line
needs the user, and pure waiting if it does not.
The waterfall
The first draft of the dashboard is usually three lines: await getUser(id),
then await getOrders(id), then await getRecommendations(id). Each await
holds the function until its answer arrives, before the next request has been
sent, so the three travel one at a time: 200 ms, then 400, then 300, which sums
to 900. On a timeline each bar starts where the last one ended — a staircase
usually called a waterfall, and the shape to look for in a browser’s network
panel.
The fix is to send every independent request before waiting for any.
Promise.all takes an array of promises and returns one that fulfils once every
input has, with their values in input order, whatever order they arrived in.
Await that and all three requests are in flight from the first millisecond: the
function waits 400 ms for the slowest, and the other two finish inside that
wait. The total is no longer the sum of the calls but the longest of them.
Nothing on your side ran in parallel. The requests always spent their time on
the network and in other services, and the one JavaScript thread spent only
microseconds sending each and reading each answer. That is also the limit:
Promise.all overlaps waiting, never computing, so three functions that each
crunch numbers for 100 ms still take 300 ms on one thread.
The scene below starts with the profile alone, adds the other two calls one per step, and keeps one bar for each way of awaiting them.
Predict first
The scene starts with getUser alone, 200 ms either way. Then getOrders (400 ms) and getRecommendations (300 ms) join, one per step. Once all three are in, what do the two bars read?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
Bar length is ms the page waits · in turn = await each · together = Promise.all
One call, getUser: 200 ms in turn or together — use Next step to go one at a time.
Watch the last step. Adding the recommendations costs the in-turn page their full 300 ms and costs the together page nothing, because they come back inside a wait the page was already paying for. That is the general rule: awaited in turn, every call adds its whole duration; started together, a call adds time only if it is the new slowest.
Not every sequential await is a mistake. If getOrders needed an account
number that arrives with the profile, it could not be sent before 200 ms, and
the fastest the page could be is 200 + 400 = 600 ms, with the recommendations,
sent at the start, long since done. Await in sequence the calls that need an
earlier answer, and send everything else first.
Loops hide the same waterfall. A for…of loop with an await inside makes one
call at a time, so fifty 80 ms lookups take four seconds, while
await Promise.all(orderIds.map(getTotal)) takes about as long as the slowest,
around 80 ms. The one-at-a-time loop is right when writes must land in order or
an API allows one request at a time. The array version has its own cost: fifty
thousand ids are fifty thousand requests in flight at once, more than any rate
limit or connection pool will take, so large batches go through a small pool of
concurrent workers.
Failures, and promises nobody awaits
Because a rejection is thrown at the await, try and catch work around
async code just as they do around synchronous code — for the promises the
function actually awaits.
Promise.all fails fast. The moment any input rejects, the combined promise
rejects with that error and the other values are discarded. The other calls are
not cancelled — a promise is a receipt for work under way, not a handle that can
stop it — so the 400 ms orders request runs to the end and its answer goes
nowhere; stopping it takes an AbortSignal passed into the call. When you want
every outcome instead, such as sending fifty receipts and reporting which
failed, Promise.allSettled waits for every input, never rejects, and gives
back one { status, value } or { status, reason } per input.
The quieter failure is a promise nobody awaits. Call saveOrder(order) without
await and nothing goes wrong on that line: the save starts, the function
carries on, and its caller hears the order was placed before it was. If the save
then fails, no try surrounds it any more, because the function has returned.
The rejection goes unhandled — logged in a browser, fatal in Node, which since
version 15 ends the process by default. The compiler does not flag the call;
the no-floating-promises rule in typescript-eslint does. TypeScript does catch
the worst variant, a forgotten await in a condition: a promise is an object,
objects are truthy, and if (canRefund(order)) with an async canRefund
approves every refund.
forEach is where floating promises hide in plain sight.
orderIds.forEach(async (id) => { await sendReceipt(id); }) starts every send
and waits for none of them, because forEach ignores whatever its callback
returns. Use for…of when the calls must run in turn, and Promise.all over
map when they need not.
Here is the endpoint from the opening, both ways, with the scene’s timings in the comments:
interface Dashboard {
user: User;
orders: Order[];
recommendations: Product[];
}
// In turn: each call is sent only after the one before it has answered.
async function loadDashboardInTurn(userId: string): Promise<Dashboard> {
const user = await getUser(userId); // back at 200 ms
const orders = await getOrders(userId); // sent at 200, back at 600
const recommendations = await getRecommendations(userId); // back at 900
return { user, orders, recommendations };
}
// Together: all three are sent first, then the function waits once.
async function loadDashboard(userId: string): Promise<Dashboard> {
const [user, orders, recommendations] = await Promise.all([
getUser(userId), // 200 ms
getOrders(userId), // 400 ms: the slowest
getRecommendations(userId), // 300 ms
]);
return { user, orders, recommendations }; // all three in hand at 400 ms
}
The two share a weakness that is easy to miss in either: if the recommendation service is down, the whole dashboard rejects — at 900 ms in turn, at 300 ms together — and the customer sees an error page instead of their own orders. For a row of suggestions, that is the wrong trade.
Try it
Keep the page when recommendations fail
Change loadDashboard so that a failed getRecommendations gives the page an
empty list instead of failing it, while a failed getUser or getOrders still
rejects. With the recommendations failing at 300 ms, the dashboard should still
arrive at 400 ms, without going back to awaiting the calls one at a time.
Hint
Leave Promise.all alone and change one of the three promises you hand it.
.catch returns a new promise: what does that promise fulfil with when its
handler returns []?
Solution
async function loadDashboard(userId: string): Promise<Dashboard> {
const [user, orders, recommendations] = await Promise.all([
getUser(userId),
getOrders(userId),
getRecommendations(userId).catch((error: unknown): Product[] => {
console.warn(`recommendations unavailable: ${String(error)}`);
return [];
}),
]);
return { user, orders, recommendations };
}.catch returns a new promise that fulfils with whatever its handler returns,
so the third input can no longer reject: a failure becomes an empty list, logged
on the way. Promise.all now fails only for the two calls the page cannot do
without, and all three are still sent at once, so the page still arrives at
400 ms.
Promise.allSettled would keep the page too, but it hands back three
{ status } objects to unpack, and the two failures that matter would have to
be rethrown by hand. It is the better fit when every result is optional; when
only one is, catching that one is shorter and says what you mean.
To see it work, swap in a recommendation service that is down:
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
// Down: it fails after its usual 300 ms.
async function getRecommendations(userId: string): Promise<Product[]> {
await sleep(300);
throw new Error(`no recommendations for ${userId}`);
}
const started = performance.now();
const { recommendations } = await loadDashboard('u42');
const ms = Math.round((performance.now() - started) / 10) * 10;
console.log(recommendations, `${ms} ms`);
// recommendations unavailable: Error: no recommendations for u42
// [] 400 ms (before the change, the await threw at 300 ms)Check yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
A job runs orderIds.forEach(async (id) => { await sendReceipt(id); }) and then logs done. There are three ids, and each send takes 100 ms. When does done appear?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
loadDashboard awaits Promise.all over its three calls. Today getRecommendations rejects at 300 ms, while getUser and getOrders succeed at 200 and 400 ms. When does the caller's catch run, and what happens to getOrders?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
placeOrder calls saveOrder(order) without await and then returns 'saved'. Its caller wraps await placeOrder(order) in try/catch. The database is down, so the save rejects after 50 ms. What happens in Node?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
A sign-up service runs await Promise.all(passwords.map(hashPassword)) over three passwords. hashPassword is an async function whose body computes for 100 ms without awaiting anything. The server has four cores. How long does that line take?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
Key takeaways
An async function returns a promise the moment it is called, and await suspends only that function, never the thread.
Calling starts the work and awaiting collects it: independent calls awaited in turn cost their sum, and started together under Promise.all they cost the slowest one.
Promise.all rejects on the first failure and cancels nothing, so catch the calls a page can do without, or use Promise.allSettled when every outcome matters.
A promise nobody awaits is a failure nobody handles, and an async callback passed to forEach is the easiest way to write one.