Back to Blog
Async2026-04-29

Async/Await Deep Dive: Beyond the Basics

Master the subtle behaviors of async/await, including microtask ordering, error propagation, and parallelism.

Most developers treat async/await as syntactic sugar over promises. That model is incomplete — and the gap shows up as subtle ordering bugs, silent unhandled rejections, and accidental serialization of independent work. This is a tour of the machinery underneath.

What await Actually Does

Per the specification, await v performs PromiseResolve(v) to coerce the operand into a promise, then attaches a resumption handler with PerformPromiseThen. Two consequences follow.

First, resumption is always a microtask, even when the awaited value is already resolved or is not a promise at all:

- async function f() { console.log(1); await 0; console.log(3); } - f(); console.log(2); prints 1, 2, 3

The function runs synchronously until the first await, then yields the rest of the current tick. await is never free: each one is a scheduling point where other queued microtasks (and, between macrotasks, rendering) can interleave. Code that reads shared mutable state before an await and writes it after has a genuine race window in otherwise single-threaded JavaScript.

Second, awaiting a native promise costs one microtask, while awaiting a thenable (any object with a then method) costs extra ticks because the thenable must be unwrapped through the promise resolution procedure. ES2019 optimized the native case down from three microtasks to one — a detail worth knowing when you compare event-loop traces across old Node versions.

Sequential vs Parallel

Naive code awaits in series, multiplying latency:

- const a = await fetchA(); - const b = await fetchB(); — waits for A even though B is independent

Parallelize by starting both operations before awaiting either: const [a, b] = await Promise.all([fetchA(), fetchB()]);. For five independent 200ms requests, that is 200ms instead of 1000ms.

Know the whole combinator family:

- Promise.all — rejects fast on the first failure; results keep input order - Promise.allSettled — never rejects; returns {status, value|reason} objects, right for independent side effects - Promise.any — first fulfillment wins; rejects only if all reject, with an AggregateError - Promise.race — first settlement of either kind wins; the standard timeout building block

The Hidden Unhandled Rejection Trap

A pattern that looks like manual parallelization is actually a crash risk:

- const pA = fetchA(); const pB = fetchB(); - const a = await pA; const b = await pB;

If pB rejects while you are still awaiting pA, there is a window where pB has no attached handler — in Node that fires unhandledRejection and, with default settings, kills the process. Promise.all([pA, pB]) attaches handlers to both immediately and is the correct form. If you must await separately, attach a no-op catch to the later promise first.

Error Handling Without Drowning in try/catch

Wrap once at the boundary — the request handler, the event callback, the job runner — not on every line. For utilities where per-call handling is genuinely needed, a tuple wrapper keeps call sites flat:

- async function safe(promise) { try { return [null, await promise]; } catch (err) { return [err, null]; } } - const [err, user] = await safe(fetchUser(id));

And learn the return await nuance: inside a try block, return await doWork() is not redundant — without the await, a rejection happens after the function has already returned and your catch never runs. Outside a try, return await adds a microtask but also keeps the function in the async stack trace, which is why the old ESLint advice to ban it was reversed.

Async Iteration Mistakes

Array.prototype.forEach ignores returned promises entirely. An async callback makes every iteration fire concurrently, completion unobservable, and rejections unhandled. Use for...of with await for sequential processing, or Promise.all(items.map(fn)) for parallel.

For unbounded concurrency, neither is right — a thousand simultaneous fetches will exhaust sockets or hit rate limits. Chunk the work or use a small pool: run N workers, each pulling the next item from a shared index. Ten lines of code, and it converts "works in dev, melts in prod" into predictable throughput.

Concurrency Is Not Parallelism Here

Promise.all does not make CPU-bound work faster: everything still runs on one thread, interleaved at await points. Async buys you concurrent waiting, not concurrent computing. If the expensive part is JSON parsing or image resizing, you need workers, not more promises.

Practical Rules

- Start independent async operations before awaiting any of them; await with Promise.all - Never pass an async callback to forEach; choose for...of or map + Promise.all deliberately - Use return await inside try blocks — it is load-bearing there - Treat every await as a point where the world can change; re-validate shared state after it - Prefer allSettled when tasks are independent and you need all outcomes - Bound your concurrency when the input size is unbounded

Async/await earns its reputation for readability, but it is a scheduler API wearing syntax clothing. The developers who internalize the microtask model stop being surprised by their own code.