async/await: parallel by default, sequential by dependency
A senior engineer's reading of async/await in JavaScript, the reflexes for choosing parallel, sequential, bounded, streamed, or fire-and-forget shapes for the asynchronous work that runs through every SaaS backend.
Look at this dashboard loader and read it the way an experienced engineer would:
const user = await getUser(userId);const org = await getOrg(orgId);const invoices = await listRecentInvoices(orgId);Three awaits, one after the other, and each one is a network round trip. If getUser takes 200ms, getOrg takes 180ms, and listRecentInvoices takes 220ms, the dashboard takes 600ms to load, the sum of all three. That looks like the unavoidable cost of doing three things, but it isn’t. Notice that org doesn’t need anything from user, and invoices doesn’t need anything from org, so the three reads are independent. An experienced engineer runs that dependency check while reading the code, then rewrites it so the three reads start at the same time. The total drops to 220ms, the slowest of the three rather than their sum.
The previous lesson on Promise combinators handed you Promise.all. This lesson teaches you when to reach for it. The chapter so far has covered the scheduler (the event loop and microtask queue) and the Promise contract with its four combinators. This lesson covers the patterns that build on both: the dependency check, the N+1 trap inside .map(async ...), the streaming iteration shape, and the discipline that keeps errors traceable in a stack trace.
Parallel by default, sequential by dependency
Section titled “Parallel by default, sequential by dependency”The rule fits in one line: if two awaits don’t share data, they can run in parallel.
You already met the mechanism, Promise.all, last lesson. The skill this lesson builds is reading a block of consecutive awaits and asking the dependency question before you reach for any tool:
- Does the next
awaitneed the previous one’s value? If no, promote them toPromise.all. If yes, sequential is the only correct shape, so keep them in order.
Compare the two shapes side by side. The before tab is the dashboard loader from the opening; the after tab is the rewrite.
const user = await getUser(userId);const org = await getOrg(orgId);const invoices = await listRecentInvoices(orgId);Three pauses, total time t1 + t2 + t3. Three await keywords on three separate lines mean three sequential pauses. The runtime pauses on line 1 until getUser settles, then starts getOrg, then listRecentInvoices. Nothing about the work forces this order. The awaits run one after another only because the code is written that way, and the three reads are independent.
const [user, org, invoices] = await Promise.all([ getUser(userId), getOrg(orgId), listRecentInvoices(orgId),]);One await, total time max(t1, t2, t3). All three calls fire the moment the array literal is evaluated, and the single await then waits for the slowest. TypeScript infers a tuple from the array literal, so user, org, and invoices keep their original types in order, with no annotation needed. Same correctness, lower latency, no extra cost.
The latency difference is the whole point of the rewrite, so it helps to see it laid out on a timeline. The figure below shows the same three reads in both shapes, with the bar widths scaled to request duration. The sequential row stacks the bars head to tail; the parallel row stacks them vertically, all starting at t = 0.
The dependency check has a second answer worth seeing. Not every block of awaits should become a Promise.all, because sometimes the next call genuinely does need the previous one’s result:
const user = await getUser(userId);const invoices = await listInvoices(user.orgId);Here listInvoices takes user.orgId as its argument. The second call cannot start until the first resolves, because the first call’s value is the second call’s input. This is the legitimate sequential shape: the rewrite doesn’t apply, and forcing parallelism here would be wrong. When the dependency isn’t obvious to a future reader, leave a comment at the call site so the next person doesn’t try to “optimize” by adding a Promise.all that breaks the data flow.
One more detail is worth watching before we move on. The destructured tuple shape works because the array literal [a, b, c] has a fixed length, which lets TypeScript infer a tuple and give each position its own type. The moment the array is built dynamically, that inference widens to a single element type:
const results = await Promise.all(ids.map((id) => getOne(id)));results is now Awaited<ReturnType<typeof getOne>>[], an array of one type rather than a positional tuple. That’s the right shape when the inputs are homogeneous, such as a list of IDs going through the same fetcher, and the element type flows naturally from the mapper. Use the destructuring shape for the three-different-reads case and the array shape for the N-of-the-same-read case. Different ergonomics, same combinator.
The N+1 trap: .map(async ...)
Section titled “The N+1 trap: .map(async ...)”The array-shape rewrite above is fine for ten items, but for five hundred it can overwhelm the backend it calls. Look at this innocent-looking shape:
const values = await Promise.all(items.map((item) => fetchOne(item.id)));Read it the way an experienced engineer would. The .map calls the async function once per item, so it returns an array of Promises. The JavaScript engine doesn’t evaluate those Promises lazily: each fetchOne call fires the moment .map reaches that index. So if items has 500 entries, the line above issues 500 concurrent network requests the moment it runs. Three things go wrong at once:
- Unbounded parallelism . Five hundred requests fire at once. The downstream service rate-limits or falls over, and the Postgres connection pool exhausts. The page never recovers, and every other request hitting the same backend during that window degrades too.
- The await-in-loop “fix” is worse. After seeing the request flood, the instinct is to wrap the work in a
forloop withawaitinside:for (const item of items) { results.push(await fetchOne(item.id)); }. This is sequential and bounded, but slow. It does fix the rate-limit, but at the cost of 500 round trips’ worth of latency stacked end to end. It trades one failure mode for another instead of solving both. - The hidden round trip. Each
fetchOneis a network call. If the work is N reads against the same backend, the right shape is often not parallel versus sequential at all: it’s one batched call that returns all N rows in a single request.
This pattern shows up so often it has a name: the N+1 problem, where one query produces the list and then N more queries fetch each item’s details. The right fix depends on what fetchOne actually does. There are three correct shapes (bounded Promise.all, pMap with a concurrency cap, and a single batched query) and one wrong one (the await-in-loop). Pick the shape by the trigger that fits each case.
const values = await Promise.all(items.map((item) => fetchOne(item.id)));Trigger: small N, cheap calls. When you can guarantee the list is bounded by the UI, such as five selected rows, the three replicas of a record, or a fixed number of providers, bare Promise.all is fine. Keep a mental ceiling of around ten. If the list can grow past that, even occasionally, this is the wrong tab. Ask where the list comes from, whether a user can grow it, and whether a paginated source can feed it. If the answer to any of those is yes, the next tab is your answer.
import pMap from 'p-map';
const values = await pMap(items, (item) => fetchOne(item.id), { concurrency: 8 });Trigger: large N, genuinely independent work, and a rate limit you need to stay under. pMap issues at most 8 concurrent calls and starts the next as each one completes. It’s the project default for bounded fan-out across the course. When the work must fan out (for example, five hundred image-processing calls against a third-party API) and the API has a rate limit you need to respect, pMap is the right reach. Pick the concurrency cap to match what the downstream service can handle, not what your machine can throw at it. This is the explicit backpressure shape: the producer (your list) is throttled to match the consumer (the downstream service).
const rows = await db .select() .from(invoicesTable) .where(inArray(invoicesTable.id, items.map((item) => item.id)));Trigger: N reads against the same backend. If fetchOne is a database lookup, the right move isn’t to optimize the parallelism but to ask the database for the whole batch in one call. One round trip, one query, the right shape. The Drizzle query builder’s inArray is the standard way to do this, compiling to WHERE id IN (...) under the hood. Reach for it whenever N awaits all hit the same backend, and the database isn’t the only backend this applies to. A REST API with a /bulk endpoint, a GraphQL query with an array argument, a webhook ingestion that takes an array of events: the pattern is general. The N+1 chapter later in the course goes deeper into the database-specific variant.
for await...of: streams and pagination
Section titled “for await...of: streams and pagination”Some async iteration genuinely is sequential. The order matters, parallelism would defeat the purpose, and the right shape is for await...of, a for...of loop that knows how to wait between iterations.
There are two canonical places you’ll see it. The first is consuming a streamed response body:
const response = await fetch('/api/export', { signal });if (!response.body) return;for await (const chunk of response.body) { processChunk(chunk);}A streamed response body is an async iterable of Uint8Array chunks. The for await...of loop suspends between iterations: while the next chunk is in flight, the surrounding async function yields back to the event loop, the way the previous lesson on the event loop described. When a chunk arrives, the loop wakes up and continues. The signal parameter threads through to cancel the read if the caller aborts, and the cancellation lesson later in this chapter covers the full signal shape.
The second canonical place is paginated SDKs. Most modern services, including Stripe, OpenAI, the AI SDK, and the Vercel platform, expose paginated results as an async iterable that fetches the next page on demand:
for await (const invoice of stripe.invoices.list({ limit: 100 })) { await process(invoice);}Under the hood, the iterator fetches one page, yields each item from that page one at a time, and only fetches the next page when the current one’s items run out. You get bounded per-page work without writing a pagination loop yourself, and the iteration is sequential because it has to be: page 2’s cursor comes from page 1’s response. The dependency is structural, not stylistic.
One thing to watch is that for await...of is sequential. Inside the loop, every iteration waits for the previous one to finish before starting. That’s the right shape when order matters, as it does for streaming chunks that must process in order, or for pages that depend on each other’s cursors. It’s the wrong shape for processing 500 independent items, which is exactly the N+1 trap from the previous section in different syntax. If you find yourself reaching for for await...of to fan out work, stop and ask whether the iterations are actually order-dependent. If they aren’t, pMap is the answer.
return await inside try/catch
Section titled “return await inside try/catch”This is a small detail with an outsized effect. When an async function returns a Promise from inside a try/catch block, leaving out the await silently breaks the catch.
First, a quick aside on try/catch itself, since this section assumes you’ve seen it. When a try block contains an awaited Promise, a rejection behaves as if it were a synchronous throw, and catch (err) binds the reason. The full discipline (typing err as unknown, narrowing safely, deciding throw versus return) comes in the next chapter on the error channel. Here we’re using it only to demonstrate the stack-trace consequence.
Compare these two functions. They look almost identical, but one catches the error and the other lets it sail past.
async function loadUser(id: string) { try { return getUser(id); } catch (err) { log.error('loadUser failed', { err }); throw err; }}return getUser(id) returns the Promise without awaiting it. The function hands back its Promise the moment the return runs, so by the time getUser’s Promise rejects, loadUser’s stack frame is already gone. The rejection escapes the function before the try block can catch it, so the catch never fires, the log line never runs, and the error’s stack trace doesn’t include loadUser at all. This is the canonical bug: the try/catch is present, looks correct, and silently does nothing.
async function loadUser(id: string) { try { return await getUser(id); } catch (err) { log.error('loadUser failed', { err }); throw err; }}The await keeps the function’s stack frame alive until getUser settles. If the Promise rejects, the rejection becomes a synchronous throw inside the try block, the catch runs, the log line fires, and the rethrow propagates the original error with loadUser in its stack trace. Inside a try/catch, return await is mandatory.
Outside a try/catch the correctness is the same, since the rejection propagates either way, but the stack trace includes the function’s frame only with return await. The course’s convention is to write return await consistently. There used to be an ESLint rule called no-return-await that flagged the pattern as redundant, and it has been deprecated for this exact reason. Write return await and take the clearer stack trace it gives you.
The async function signature
Section titled “The async function signature”Here is a detail that surprises students coming from typed languages with explicit task or future types. An async function always returns a Promise<T>. The keyword wraps the function’s return value into the Promise’s fulfillment, and the function’s throw into the Promise’s rejection. TypeScript handles the wrap automatically, so your job is to declare what the function fulfills with, not the Promise around it.
async function getUser(id: string): Promise<User> { const [user] = await db .select() .from(usersTable) .where(eq(usersTable.id, id)); return user;}One more shape is worth recognizing: await is also legal at the top level of an ES module. The previous chapter on ES modules covered when to reach for top-level await versus lazy initialization. As a reminder, use it only when the module’s exports are derived from async work. For everything else, do the async work inside a function and let the caller await it.
Fire-and-forget, with an explicit .catch
Section titled “Fire-and-forget, with an explicit .catch”Sometimes you want to start async work and not wait for it. A call that pings the analytics endpoint after sign-in has no return value the caller needs and no failure path the caller should block on. The naive shape is:
logEvent('signed-in', { userId });This is the trap the previous lesson named. An unhandled rejection from a bare async call crashes Node 20+ by default, and in the browser it silently fires window.onunhandledrejection. The fix is to attach an explicit .catch and mark the dropped Promise with void so the linter knows you mean it:
void logEvent('signed-in', { userId }).catch((err) => log.error('logEvent failed', { err }));Two operators, two purposes. The void operator returns undefined and signals to the no-floating-promises lint rule that you’ve thought about this and dropped the Promise on purpose. The .catch swallows any rejection so it can’t crash the process. Always pair them. A .catch without void triggers the lint rule, and a void without .catch crashes on rejection. Both together is the correct fire-and-forget shape.
That said, fire-and-forget is only for small, disposable work. If the work needs durability, meaning retries on transient failure, observability across runs, or persistence across deploys, the right answer isn’t to drop a Promise at all. It’s to enqueue a background job. The unit on background work later in the course covers that pattern with Trigger.dev. Until then, treat fire-and-forget as a tool for work that genuinely is fire-and-forget, such as analytics pings or log writes that aren’t load-bearing for correctness, not as a substitute for a job runner.
Practice: pick the shape
Section titled “Practice: pick the shape”The six shapes you just met cover almost every async pattern in a SaaS codebase. The exercise below sorts scenarios by which shape fits. Read each item, run the dependency check (and the “what is N?” check for any .map(async ...) shape), and drop it in the right bucket.
Run the dependency check (and the 'what is N?' check) on each scenario, then drop it in the shape that fits. Drag each item into the bucket it belongs to, then press Check.
Practice: rewrite to Promise.all
Section titled “Practice: rewrite to Promise.all”This exercise puts the dependency check into your hands. The starter is a dashboard loader that runs four sequential awaits against fake helpers, none of which depend on the others’ results. Your job is to rewrite the function so the four reads happen in parallel. The tests check three things: that the return shape is unchanged, that all four helpers are in flight at the same time rather than back to back, and that the function body actually uses Promise.all. That last check means you can’t pass the parallelism test by removing awaits and breaking correctness.
Rewrite loadDashboard so all four reads run in parallel via Promise.all. Keep the return shape the same. The four helpers (getUser, getOrg, getRecentInvoices, getOrgMembers) are independent — none needs another's result.
Reveal solution
async function loadDashboard(userId, orgId) { const [user, org, invoices, members] = await Promise.all([ getUser(userId), getOrg(orgId), getRecentInvoices(orgId), getOrgMembers(orgId), ]); return { user, org, invoices, members };}Four reads, one await, same return shape. The wall-clock time becomes the slowest of the four rather than the sum, and the destructured tuple keeps each name’s type without an annotation.
Free play: feel the latency difference
Section titled “Free play: feel the latency difference”The exercise above is graded; the playground below is just for experimenting. Two functions wrap the same four helpers, one running them sequentially and the other through Promise.all, with console.time brackets around each call. Run both and read the output. The numbers shouldn’t surprise you anymore. The point is to feel the difference at runtime, not just on a timeline.
External resources
Section titled “External resources”The `await` operator's full semantics — including the suspension behavior and the rule that the surrounding function must be `async`.
The bounded-concurrency helper this lesson installs as the project default. Includes the `concurrency`, `stopOnError`, and `signal` options.
The async iteration shape, with examples for both async iterables and async generators.
The async iteration protocol on `response.body`, including the cancel-on-exit behavior and the `preventCancel` escape hatch.