What does await actually do between the moment it suspends and the moment it resumes?
That's the gap where most production bugs around async code live. Not in the function itself, not in the promise it's awaiting, in the few microseconds in between, where V8 is busy scheduling jobs you didn't write and your stack trace is quietly losing frames. If you can describe what happens in that gap, you can debug almost any async issue. If you can't, you'll be guessing at races and ordering bugs forever.
So let's open it up. By the end of this you'll know: what V8 turns an async function into, why a single await used to schedule three microtasks (and now schedules one), why the engine creates a "throwaway" promise that has no observable purpose, how the microtask queue interleaves with process.nextTick, and which gotchas survived the spec rewrite of 2018.
The First Surprise: async Is A Compiler Pass, Not A Keyword
When you write this:
async function getUser(id) {
const row = await db.query('select * from users where id=$1', [id]);
return row;
}
V8 reads that source, parses the async and await tokens, and marks the function as resumable. That's the actual property that matters. A resumable function is a function whose execution can be paused at known points (the await expressions), have its local frame stashed somewhere, and be picked back up later from exactly that point with the same locals.
That's the same machinery that powers generator functions. async/await is generators-plus-promises, in spec terms, a generator that yields to the engine, and a hidden promise that signals when to resume. The TC39 proposal that introduced it literally says so: "async functions are a simpler form of generators that always return a Promise."
The transformation, conceptually, looks like this:
function getUser(id) {
const implicit_promise = newPromise();
resume(function* () {
const row = yield db.query('select * from users where id=$1', [id]);
implicit_promise.resolve(row);
});
return implicit_promise;
}
Two things matter in that sketch. First, the function returns immediately, not with the user row, but with a promise. That's the implicit promise. The caller gets a handle to "this will eventually have a value" before any database work happens. Second, the body is split at every await into a sequence of resumable chunks. Each chunk runs synchronously to the next await, then control flows back to the caller through the implicit promise.
This is why an async function always returns a promise, even one that just does return 42:
async function answer() {
return 42;
}
answer(); // → Promise { 42 }
Equivalent to Promise.resolve(42). There's no way to make an async function return anything else. If you throw inside one, the implicit promise rejects with the thrown value. The function body never gets to "throw out of" the function the way a synchronous one does, because by the time the throw happens, the synchronous caller is already gone and holding a promise.
What await Does In Detail
The interesting bit is what happens at the await line. According to the ECMAScript spec and the V8 team's writeup on await's implementation, the steps for await v are:
- Wrap
vinto a promise. Ifvis already a promise, reuse it (since V8 7.2, more on this below). - Attach handlers to that promise so the async function resumes when it settles.
- Suspend the async function. Save its frame.
- Return the implicit promise to whoever called the async function.
Then, when the awaited promise settles:
- Schedule a microtask that will resume the suspended function with the settled value.
- The microtask runs, picks up the saved frame, and execution continues from the line after
await.
It looks like four steps. It used to be six microtasks of dancing.

To make that concrete, here's a tiny program that lets you observe the order:
const p = Promise.resolve();
(async () => {
await p;
console.log('after:await');
})();
p.then(() => console.log('tick:a'))
.then(() => console.log('tick:b'));
What order does this print? Most people guess after:await first because p is already resolved. The actual order in Node.js 12 and later, which is what every supported Node version does today, is:
tick:a
after:await
tick:b
The async function runs up to the await, suspends, and yields control. Then the synchronous code keeps going and registers .then(tick:a).then(tick:b). Then the microtask queue starts draining. The first microtask is tick:a. The second is the resumption of the async function (after:await). The third is tick:b. The promise the async function awaits and the one the chained .thens use are the same promise, but each step in the chain adds one microtask to the queue.
This was actually wrong in Node.js 8, and the V8 team called out the bug in their Faster async functions and promises post. Node 8 printed after:await first because of a microtick-skipping bug. That bug turned out to be faster than the spec, so the spec was changed to match. The behavior above is the post-fix spec, shipped in V8 v7.2 / Chrome 72 / Node.js 12.
The "Throwaway Promise" Nobody Told You About
Here's a fact about await that almost nobody knows unless they've read the V8 source: every await in Node.js (up to some recent optimizations) used to allocate two promises behind the scenes, even if you only awaited a single existing promise.
One was the wrapper promise, the spec wraps the awaited value in a fresh promise so that the rest of the machinery has a uniform shape. The other was something the V8 team called the throwaway promise. It's used as the receiver of performPromiseThen, the internal operation that attaches a .then handler. The throwaway exists for one reason: the internal API needs a promise to attach handlers to. The chain it forms is never observed by your code. It is created, used once, and garbage-collected.
The fast-async V8 post puts numbers on this. Before the 7.2 optimization, every single await of a promise produced:
- One extra wrapper promise.
- One throwaway promise.
- A minimum of three microtask queue ticks before resume.
After the optimization (V8 v7.2, Node.js 12+):
- The wrapper is skipped if the value is already a promise.
- The throwaway promise is also skipped, unless
async_hooksis active. - A minimum of one microtask tick before resume.
That's a 3× reduction in queue ticks for the common case. It's also why the V8 team's parallel benchmark improved 8× on Promise.all-heavy workloads between Node 7 and Node 10, fewer microtasks per await means less time spent in the queue and more time actually running your code.
There's still one caveat. If anything in your process is using async_hooks, and Node's built-in AsyncLocalStorage is built on top of async_hooks, the throwaway promise is still created, because the before and after hooks need to run in its context. That's a small but real performance cost of running async context tracking in production. Most servers ship it anyway because the observability is worth it.
Tasks, Microtasks, And process.nextTick
To follow what your code actually does, you need three queues in your head, not one. The async-await machinery talks to all three.
A task (sometimes called a macrotask) is something like an I/O completion, a timer firing, or a setImmediate callback. Tasks run one at a time. Each task drains the microtask queue completely before returning control to the event loop.
A microtask is what .then handlers and await resumes get scheduled as. The microtask queue is drained after every task, and after every microtask while the queue still has items, meaning a microtask that schedules another microtask delays the next task indefinitely.
process.nextTick is Node-specific and isn't even technically a microtask in V8 terms, it's Node's own queue, drained before the V8 microtask queue. The order, exhaustively, is:
- Synchronous code runs to completion.
- Node drains the
nextTickqueue. - V8 drains the microtask queue (promises,
awaitresumes,queueMicrotask). - Repeat from 2 until both queues are empty.
- Then the event loop moves to the next phase (timers, I/O, etc.).
You can see this directly:
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
Output:
sync
nextTick
promise
queueMicrotask
setTimeout
setImmediate
The first thing printed is the synchronous code. Then process.nextTick runs before any promise handlers because Node drains its own queue first. Then the microtask queue runs in registration order: promise was queued before queueMicrotask so it goes first. Then the event loop advances to the timer phase. (setImmediate vs setTimeout(0) ordering at the very start can flip, there's a known quirk where setTimeout(fn, 0) is bounded below by ~1ms, but inside an I/O callback setImmediate is guaranteed to fire first.)

What does this have to do with await? Everything. Each await schedules its resume as a microtask. That means a function with three sequential awaits, assuming all three awaited promises are already settled, needs the microtask queue to drain three times before reaching the function's return value. Three queue rounds is fast, but it is not zero, and a tight loop of awaits inside a hot request handler is measurable.
A direct consequence: if you're trying to "yield to the event loop" to let a timer fire, awaiting a resolved promise is not enough. The microtask queue gets drained without giving timers a chance. You need setImmediate or setTimeout(fn, 0) to actually let the event loop advance.
Error Propagation: The Promise Doesn't Throw, It Rejects
When code inside an async function throws, what gets thrown isn't really thrown, the implicit promise rejects. That changes how you reason about error boundaries.
async function getUser(id) {
if (!id) throw new Error('missing id');
return await db.query(...);
}
getUser(); // returns Promise.reject(new Error('missing id'))
The throw is synchronous from the function's perspective but observed asynchronously by the caller. Any try/catch wrapping getUser(id) won't catch it, there's no exception to catch by the time the catch block runs, because getUser returned (with a rejected promise) successfully. You need either await getUser() inside a try/catch, or .catch() on the returned promise.
This is also why "fire and forget" async functions are dangerous. If you call an async function without await and without .catch(), and it rejects, you have an unhandled rejection. Starting with Node.js 15, unhandled rejections crash the process by default, --unhandled-rejections=throw is the default. Before 15, you got a warning and your process kept running with a broken invariant. The 15-and-later behavior is correct; the pre-15 behavior was the dangerous one.
The pattern that bites people in production is the one that looks defensive:
async function notifyAll(users) {
users.forEach(async user => {
try {
await sendEmail(user);
} catch (err) {
logger.error({ user, err });
}
});
logger.info('all notifications dispatched');
}
That logs all notifications dispatched before a single email has been sent. Array.prototype.forEach is not promise-aware. It calls each async callback, gets back a promise it ignores, and returns immediately. The try/catch around the await works for each individual send, but the outer function returns before any of the sends finish.
If you need sequential, use for...of:
for (const user of users) {
try {
await sendEmail(user);
} catch (err) {
logger.error({ user, err });
}
}
If you need parallel and don't care about partial failure, use Promise.all and accept that the first rejection cancels your view of the rest:
await Promise.all(users.map(u => sendEmail(u).catch(err => logger.error({ u, err }))));
If you want every result regardless of failure, use Promise.allSettled and inspect the array. The right choice is rarely forEach, and there's no version of forEach that does what beginners assume it does.
Zero-Cost Async Stack Traces, Or: Where Did foo Go?
Run this in Node.js 8:
async function foo() {
await bar();
return 42;
}
async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}
foo().catch(err => console.log(err.stack));
The output:
Error: BEEP BEEP
at bar (trace.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
...
foo is gone. Not because the runtime forgot, because foo had already suspended and returned by the time bar threw. The synchronous stack at the moment of throw genuinely does not contain foo.
This was such a common debugging pain point that V8 7.3 (Node.js 12) shipped zero-cost async stack traces, on by default. The trick: at every await, the engine knows two things, the function that just suspended (foo) and the line it'll resume on. That's enough to reconstruct the async caller chain without any runtime overhead, because the information is already on hand. There's no async-frame capture, no extra allocation, no slow-down. The V8 team's zero-cost async stack traces design doc describes the implementation.
After V8 7.3, the same program prints:
Error: BEEP BEEP
at bar (trace.js:8:9)
...
at async foo (trace.js:2:3)
The at async foo line is the reconstruction. It tells you exactly where bar was awaited from, which, in real applications, is often the only thing you need to debug a production rejection.
Three caveats. First, this works for await, not for Promise.prototype.then. If your code chains with .then(...), you get the synchronous part of the stack only. The async glue between .thens isn't reconstructible the same way, the V8 team explains why await beats .then for stack traces. Second, it only enriches Error#stack. The runtime stack used by V8's interpreter is unchanged, so the cost really is zero. Third, errors created with new Error() and never thrown won't have the async frames, the reconstruction triggers when the error propagates through an await.
A Worked Example: One Request, Many Microtasks
Put it all together with one realistic handler:
async function handler(req: Request): Promise<Response> {
const user = await authenticate(req); // (1) await
const [orders, prefs] = await Promise.all([ // (2) await on Promise.all
db.orders.find({ userId: user.id }),
db.prefs.find({ userId: user.id }),
]);
const enriched = await Promise.all( // (3) await on Promise.all
orders.map(o => attachProduct(o)),
);
return Response.json({ user, prefs, orders: enriched });
}
What V8 schedules, in rough order, for one request:
- The synchronous part of
handlerruns until the firstawait. Implicit promise allocated. authenticate(req)returns a promise.handlersuspends, registers a microtask to resume.- Whatever I/O
authenticatedoes kicks off (a network call, a JWT decode, etc.). - The microtask queue drains nothing yet, the handler is the only thing on it, and it's waiting.
- The event loop advances, services I/O, eventually the authentication promise settles.
- Settling that promise schedules the resume microtask for
handler. Microtask queue drains,handlerresumes at lineconst [orders, prefs] = .... Promise.allis called, which internally allocates one promise per input and one combinator promise. Each input is awaited concurrently.handlersuspends again.- The two DB queries return promises that eventually resolve. As each settles, it schedules a microtask. The combinator's microtask schedules
handler's resume. handlerresumes, callsorders.map(...). The map kicks off allattachProductcalls in parallel. Each returns a promise.Promise.allagain,handlersuspends again.- Final resume builds the response.
Three awaits, three resumes. In the V8 12+ world, that's three microtask ticks for the user code, plus the microtasks that the libraries underneath (the database driver, the HTTP layer, AsyncLocalStorage if you use it) add to each await they perform internally. In the V8 7.1- world, it would have been at least nine. The difference at one request is invisible. At ten thousand concurrent requests, it's the kind of optimization that lets a single Node process saturate a 10G NIC.
The other thing worth noticing: in step 9, orders.map(o => attachProduct(o)) returns an array of promises. If you forgot the surrounding Promise.all and wrote await orders.map(o => attachProduct(o)), the array isn't a promise, so await would wrap it in Promise.resolve(array) and resume immediately. You'd "succeed" without actually waiting for any of the product fetches. A unit test with a fast mock would pass. The bug would only show up when the product data wasn't there in production.
Gotchas That Survived The Spec Rewrite
A short list of behaviors that still trip up senior engineers:
await of a non-thenable is a free microtask. await 42 is valid, takes one microtask, and returns 42. The engine wraps the value into a promise (cheap) and schedules the resume. People sometimes use await null to yield to the microtask queue between two synchronous chunks. It works, but await null doesn't yield to the event loop, only to the microtask queue. To let timers and I/O run, use await new Promise(r => setImmediate(r)).
return await inside try/catch is not redundant. Without the await, the rejection happens after the function returns, outside the try:
async function bad() {
try {
return rejectsSometimes(); // bare return, error escapes the try
} catch (err) {
return fallback();
}
}
With it:
async function good() {
try {
return await rejectsSometimes();
} catch (err) {
return fallback();
}
}
The V8 team used to discourage return await for performance reasons, but the post-7.2 optimization made the cost negligible, and the readability/correctness win is real. ESLint's no-return-await rule has been rolled back to allow it in try blocks.
A rejected promise that's later awaited is fine. A rejected promise that's never awaited or .catch-ed is an unhandled rejection. Node 15+ crashes on it. The window where the engine decides "this rejection is unhandled" is at the end of the microtask checkpoint, so Promise.reject('x').catch(...) registered on the next line is fine, but Promise.reject('x') left dangling after a microtask drain is not.
await inside a setInterval callback doesn't prevent overlapping runs. The interval fires regardless of whether the previous callback finished. If you need a non-overlapping interval, you need to manage it yourself with setTimeout recursion or a guard flag.
The microtask queue can starve the event loop. A while loop that awaits a resolved promise on every iteration will dominate the microtask queue and prevent timers, I/O callbacks, and network responses from ever running. The runtime won't crash. It'll just appear hung. The fix is the same as the yielding fix above, await new Promise(r => setImmediate(r)) once per iteration breaks out to the next event loop phase.
When To Reach For What
Async/await is the default. Use it. The shape of synchronous-looking code with linear try/catch is the right shape for most server handlers, scripts, and tests.
Promise.all is for fan-out: you have N independent operations and you want them to run concurrently. The total time is roughly the slowest one. If any one rejects, you lose all of them.
Promise.allSettled is for fan-out where partial failure is fine. You want one row per input, success or failure. Dashboard widgets, batch jobs, anything where "best effort across N things" is the requirement.
Promise.race is for "first one wins", usually combined with setTimeout to implement a deadline. It does not cancel the losers. The losing promises keep running and their rejections, if any, can become unhandled.
Promise.any is the inverse of race, first fulfilled wins, rejections are pooled. Useful for "any of these mirrors will do."
Hand-written .then chains are mostly obsolete. The two cases where you'd still reach for them: when you genuinely need to compose promises without an async wrapper (rare), and when you're writing a library that should work with non-native thenables (also rare). Otherwise, prefer await, it's faster post-V8-7.2, it produces better stack traces, and it reads better.
What Changed, And When
A condensed timeline so the "what version do I need" question stops being mysterious:
- 2017, ES2017 / ES8:
async/awaitfinalized in the spec, stage 4. - 2017, Node.js 8 (V8 6.2): First Node.js LTS with native async/await. No flags needed.
- 2018, Node.js 10 (V8 6.8): Async iteration (
for await...of) stable. Throughput on promise-heavy code measurably improved over Node 7. - 2018, V8 7.2 / Chrome 72 / Node.js 12: The microtask optimization lands.
awaitof a promise goes from 3 microticks to 1. Spec change accepted. - 2019, V8 7.3 / Node.js 12: Zero-cost async stack traces on by default.
- 2020, Node.js 14:
Promise.anyandAggregateErrorship. - 2020, Node.js 15: Unhandled rejections terminate the process by default. Top-level
awaitin ES modules.
If you're on Node.js 18 or newer, every optimization in this article applies to you. If you're on Node.js 14, you're missing top-level await and the unhandled-rejection-terminate default, both of which you should opt into manually.
What To Do With Any Of This
The point of knowing how await works under the hood isn't to be clever about it. It's to be able to read your own code and predict what'll happen. You should be able to look at a handler and answer:
- How many microtask ticks does this function need before it returns?
- If this promise rejects, is the rejection caught, or does it become unhandled?
- Could this loop starve the event loop?
- If a stack trace lands in my logs, will I be able to tell which function awaited where?
If you can answer those in under five seconds for your code, you understand async/await. If you can't, the answer isn't to add more try/catch. It's to stare at one of your routes long enough to actually trace the queue. Once you've done that for one realistic handler, you've done it for all of them.






