09 · Concurrency

Async / Await

8 min read · 5 questions · 1 exercise

Await pauses only the function it is written in, so how long async code takes depends on how much work it starts before it stops to wait.

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?

  1. That reads Promise.all as a tidier way to write the same three awaits. It changes when the calls start, not how long each one takes: all three are sent before the function waits, so their waiting overlaps instead of adding up.

  2. In turn, each call is sent when the one before it answers: 200 + 400 + 300 = 900. Together, all three are sent at 0 ms and the page waits for the slowest, getOrders at 400. getRecommendations is back at 300, inside that wait, so it never moved the bar.

  3. 300 is the average of the three calls, as if their time were shared out between them. Nothing is shared: each call still takes its full time, and Promise.all cannot fulfil until the slowest is back, at 400 ms.

  4. 200 is the fastest call. Settling with the first input to settle is what Promise.race does; Promise.all needs every value, so it waits for the last one to arrive, at 400 ms.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Three calls, two ways to await them

Bar length is ms the page waits · in turn = await each · together = Promise.all

in turn200
together200

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?

  1. That reads forEach as a loop that waits for each callback. It does call them one after another, but each callback returns a promise at its first await, and forEach drops that promise and moves straight on.

  2. The three sends do run together and finish at about 100 ms, but nothing waits for them. Waiting for every one takes await Promise.all(orderIds.map(sendReceipt)), which is probably what this code meant.

  3. Each callback starts its send and returns a promise at the await, and forEach ignores the promises. Run it and done is logged about 1 ms in, with all three receipts sent about 100 ms later.

  4. Nothing rejects them. forEach accepts any function, TypeScript compiles this without a word, and the sends really happen. That is what makes it dangerous: it looks as if it works.

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?

  1. Promise.all rejects the moment any input does, with that error. It cannot stop the other calls, because a promise only reports on work already under way. Run it and the catch fires at 300 ms, while the orders still arrive at 400, to be thrown away.

  2. Rejecting the combined promise does not reach back into the calls. A JavaScript promise has no cancel; stopping the request itself takes an AbortSignal passed into getOrders.

  3. That is Promise.allSettled, which waits for all three and never rejects. Promise.all fails fast: one rejection is enough to settle it, 100 ms before the orders are back.

  4. There is no partial result. Promise.all either fulfils with every value or rejects with the first failure; it has no slot for an undefined in place of one.

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?

  1. The try covers what the caller awaits, and the caller awaits placeOrder, which fulfilled with saved before the save failed. The rejection belongs to a promise nobody awaited, so no catch anywhere is waiting for it.

  2. Leaving out await is not an error. The call starts the save and returns a promise, which the line simply drops. TypeScript compiles it too; catching it takes a lint rule such as no-floating-promises.

  3. An async function does not wait for promises it never awaits. placeOrder returns saved at once, before the save has finished, let alone failed.

  4. The caller is told the order was saved at 0 ms. At 50 ms the save rejects with no handler attached, which Node has treated as fatal by default since version 15: it prints the error and exits with code 1. A browser would log it and carry on, with the order still lost.

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?

  1. Promise.all runs nothing; it only waits for promises that already exist. All three calls run on the one JavaScript thread while the other cores sit idle. Using them takes worker threads.

  2. Each call runs its 100 ms loop to the end before it returns its promise, so map takes 300 ms just to build the array, and Promise.all has nothing left to wait for. A native call such as crypto.scrypt in Node is different: it runs on a thread pool, and there Promise.all does overlap the work.

  3. async changes what a function returns, not where it runs. The body runs synchronously on the thread that called it, up to its first await, and this body has none.

  4. Calling a function runs it; await starts nothing. The three hashes are computed during the map, before Promise.all is even called.

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.