async/await: parallel by default
Reading async/await in JavaScript and choosing the right shape for each block of awaits, parallel, sequential, bounded, streamed, or fire-and-forget.
Here is a dashboard loader:
const user = await getUser(userId);const org = await getOrg(orgId);const invoices = await listRecentInvoices(orgId);Three awaits, one after another, each a network round trip. If getUser takes 200ms, getOrg 180ms, and listRecentInvoices 220ms, the dashboard loads in 600ms, the sum of all three. That looks unavoidable, but it isn’t: org needs nothing from user, and invoices needs nothing from org, so the three reads are independent. Start them at the same time and the total drops to 220ms, the slowest of the three rather than their sum.
This lesson is about closing that gap between sum and max: reading a block of awaits, asking which ones actually depend on each other, and picking the shape that matches.
Parallel by default, sequential by dependency
Section titled “Parallel by default, sequential by dependency”The rule is one line: if two awaits don’t share data, they can run in parallel.
So ask the dependency question of each consecutive await:
- Does the next
awaitneed the previous one’s value? If no, the reads are independent: promote them toPromise.all. If yes, sequential is the only correct shape: keep them in order.
const user = await getUser(userId);const org = await getOrg(orgId);const invoices = await listRecentInvoices(orgId);Three pauses, total time t1 + t2 + t3. The runtime waits on line 1 until getUser settles, then starts getOrg, then listRecentInvoices. Nothing about the work forces this order; the reads are independent and run one after another only because the code is written that way.
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 when the array literal is evaluated, and the single await waits for the slowest. TypeScript infers a tuple from the array, so user, org, and invoices keep their original types in order, no annotation needed. Same correctness, lower latency.
That latency difference is easiest to see on a timeline. The same three reads appear in both shapes below, with bar widths scaled to request duration.
Not every block of awaits should become a Promise.all, because sometimes the next call really does need the previous one’s result:
const user = await getUser(userId);const invoices = await listInvoices(user.orgId);Here listInvoices takes user.orgId, so the second call cannot start until the first resolves. This is the legitimate sequential shape, and forcing parallelism would break it. When the dependency isn’t obvious, leave a comment at the call site so the next reader doesn’t “optimize” it into a Promise.all.
One more detail about the types. The destructured tuple works because the array literal [a, b, c] has a fixed length, which lets TypeScript give each position its own type. Build the array dynamically and that inference widens to a single element type:
const results = await Promise.all(ids.map((id) => getOne(id)));Now results is Awaited<ReturnType<typeof getOne>>[], an array of one type rather than a positional tuple. That is the right shape when the inputs are homogeneous, such as a list of IDs through the same fetcher. Use destructuring for three different reads, the array for N of the same read: different ergonomics, same combinator.
The N+1 trap: .map(async ...)
Section titled “The N+1 trap: .map(async ...)”The array-shape rewrite is fine for ten items, but for five hundred it can flood the backend it calls:
const values = await Promise.all(items.map((item) => fetchOne(item.id)));.map calls the async function once per item, returning an array of Promises, and nothing is lazy: each fetchOne fires the moment .map reaches its index. So 500 entries means 500 concurrent network requests at once. Two things go wrong:
- Unbounded parallelism . The downstream service rate-limits or falls over, the Postgres connection pool exhausts, and every other request hitting that backend during the window degrades too.
- The hidden round trip. Each
fetchOneis a network call. If the work is N reads against one backend, the right shape isn’t parallel versus sequential at all: it’s one batched call that returns all N rows in a single request.
The tempting fix makes it worse: a for loop with await inside, for (const item of items) { results.push(await fetchOne(item.id)); }. That’s sequential and bounded, so it stops the flood, but it stacks 500 round trips end to end, trading one failure mode for another.
This is the N+1 problem: one query produces the list, then N more fetch each item’s details. Pick the right shape by what fetchOne does.
const values = await Promise.all(items.map((item) => fetchOne(item.id)));Trigger: small N, cheap calls. When the list is bounded by the UI, such as five selected rows or a fixed set of providers, bare Promise.all is fine. Keep a mental ceiling of around ten. If a user can grow the list or a paginated source can feed it, even occasionally, 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, independent work, a rate limit to stay under. pMap keeps at most 8 calls in flight and starts the next as each completes, for example across five hundred image-processing calls against a third-party API. It’s the project default for bounded fan-out . Match the concurrency cap to 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.
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, don’t optimize the parallelism, ask the database for the whole batch in one call. Drizzle’s inArray compiles to WHERE id IN (...). The pattern is general: a REST API with a /bulk endpoint, a GraphQL query with an array argument, a webhook ingestion that takes an array of events. Whenever N awaits hit the same backend, reach for the one-request shape.
for await...of: streams and pagination
Section titled “for await...of: streams and pagination”Some async work is sequential by nature: the order matters and parallelism would defeat the purpose. The shape for it is for await...of, a for...of loop that waits between iterations.
The first place you’ll reach for it is 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 body is an async iterable of Uint8Array chunks. While the next chunk is in flight, the loop suspends and the surrounding async function yields back to the event loop; when the chunk arrives, the loop continues. The signal parameter cancels the read if the caller aborts, covered in full by the cancellation lesson later in this chapter.
The second is paginated SDKs. Services like Stripe, OpenAI, and the AI SDK 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);}The iterator fetches one page, yields its items one at a time, and fetches the next page only when the current one runs out. You get bounded per-page work without writing the pagination loop yourself, and the iteration must be sequential: page 2’s cursor comes from page 1’s response.
Because every iteration waits for the previous one, for await...of is the right shape only when order matters, as it does for ordered chunks and cursor-dependent pages. For 500 independent items it’s the N+1 trap in new syntax. Before reaching for it to fan out work, ask whether the iterations are really order-dependent; if they aren’t, use pMap.
return await inside try/catch
Section titled “return await inside try/catch”When an async function returns a Promise from inside a try/catch, leaving out the await silently breaks the catch.
Inside the try, an awaited Promise that rejects behaves like a synchronous throw, and catch (err) binds the reason. Compare these two functions: they look almost identical, but one catches the error and the other lets it escape.
async function loadUser(id: string) { try { return getUser(id); } catch (err) { log.error('loadUser failed', { err }); throw err; }}return getUser(id) hands back the Promise the moment return runs, so loadUser’s stack frame is gone before that Promise rejects. The rejection escapes past the try: the catch never fires, the log line never runs, and loadUser is missing from the stack trace. The bug is invisible because the try/catch is present and looks correct.
async function loadUser(id: string) { try { return await getUser(id); } catch (err) { log.error('loadUser failed', { err }); throw err; }}The await keeps the stack frame alive until getUser settles. A rejection then throws inside the try, the catch runs, the log line fires, and the rethrow carries the original error with loadUser in its stack trace. Inside a try/catch, return await is mandatory.
Outside a try/catch the rejection propagates either way, but only return await keeps the function’s frame in the stack trace, so the course writes it consistently.
The async function signature
Section titled “The async function signature”An async function always returns a Promise<T>. The keyword wraps the function’s return value into the Promise’s fulfillment and its throw into the Promise’s rejection. TypeScript adds the wrap for you, so annotate 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;}await is also legal at the top level of an ES module, but reach for it only when the module’s exports are derived from async work; otherwise do the 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 without waiting for it. A call that pings the analytics endpoint after sign-in returns nothing the caller needs and has no failure the caller should block on. The naive shape is:
logEvent('signed-in', { userId });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:
void logEvent('signed-in', { userId }).catch((err) => log.error('logEvent failed', { err }));The void operator signals to the no-floating-promises lint rule that you dropped the Promise on purpose; the .catch swallows any rejection so it can’t crash the process. Pair them always: a .catch without void trips the lint rule, and a void without .catch crashes on rejection.
Fire-and-forget is only for small, disposable work, such as analytics pings or non-load-bearing log writes. If the work needs durability, meaning retries on failure, observability, or persistence across deploys, don’t drop a Promise at all: enqueue it on a background job runner instead.
Practice: pick the shape
Section titled “Practice: pick the shape”Each scenario maps to exactly one shape.
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”The starter is a dashboard loader that runs four awaits in sequence, yet no read depends on another’s result. Rewrite it so all four run in parallel.
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. Wall-clock time drops to the slowest read instead of 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”Two functions wrap the same four helpers, one awaiting them in sequence and the other through Promise.all, with console.time around each. Run both and compare the numbers.
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.