Run this in Node or a browser console:
| |
The output is 1 4 3 2. A setTimeout with a 0ms delay still runs after the promise callback, and both wait for every line of synchronous code below them. async/await is built on this ordering, not on something new. Most of the surprising behavior in async JavaScript comes from writing await as if it paused the whole program, when it only pauses the function it’s in.
Why setTimeout runs after a promise with no delay
JavaScript runs your code on one thread with one call stack. Functions push onto it when they’re called and pop off when they return, and nothing else runs while the stack has anything on it. Timers, network responses and promise resolutions are handled by the runtime around the stack (the browser, or libuv in Node), which queues a callback for later.
Which queue the callback lands in depends on what kind of work it is:
- The task queue (also called the macrotask queue) holds
setTimeoutandsetIntervalcallbacks, I/O completions, and UI events. The event loop takes exactly one task from this queue per iteration. - The microtask queue holds promise callbacks (
.then,.catch,.finally) and anything scheduled withqueueMicrotask. The event loop drains this queue completely, including microtasks added while draining it, before moving on.
One iteration of the loop looks like this:
| |
So in the snippet above, console.log("4") ends the synchronous run, the microtask queue drains and prints 3, and only then does the loop pick up the timer callback that prints 2. If you’ve worked with a message queue, the model is familiar: work waits in line and a single consumer handles one item at a time. The twist is that JavaScript has two lines, and the microtask line always gets served first.
A side effect worth knowing: because the microtask queue must be empty before any task runs, a promise chain that keeps scheduling more promise callbacks will starve timers and, in a browser, block repaints.
Where await actually pauses execution
An async function always returns a promise, even with no return and no await in its body. await pauses that one function: everything after it becomes a continuation that runs as a microtask once the awaited value settles. It’s syntax over .then(), not a different mechanism.
| |
This prints start, a, end, b. example() runs synchronously up to await null, then hands control back to its caller. console.log("end") runs before example resumes, because the resume is queued as a microtask and microtasks wait for the current synchronous code to finish.
That one fact, await yields to the microtask queue and nothing else, explains every mistake below. It’s also why async/await pushed out nested .then() chains: a chain that handles errors at three different depths is the tangled control flow that spaghetti code describes, and sequential await statements put the order of operations back on the page, top to bottom.
What happens when you forget the await keyword
Drop an await and you get the promise object instead of its result. No error, just wrong data three lines later:
| |
getUser(4821) returns immediately with a pending Promise, and a Promise has no name property. The fix is one keyword: const user = await getUser(id). What makes this bug expensive is the silence. Nothing throws at the faulty line; you find undefined somewhere downstream, or [object Promise] in a template string.
Should you use await in a loop or Promise.all?
await inside a for loop finishes each iteration before starting the next, even when the iterations don’t depend on each other:
| |
If each getUser call takes 200ms and ids has three entries, this takes about 600ms. The second request doesn’t start until the first response is back. When the requests are independent, start them all and await the batch:
| |
All three requests now fire together and the function returns in about 200ms, the time of the slowest one. Promise.all rejects as soon as any single promise rejects. If you need every result regardless of individual failures, Promise.allSettled gives you a status for each instead of short-circuiting.
Sequential await is the right call in two cases. One: each iteration needs the previous result, like paginating an API with a cursor from the last response. Two: you’re deliberately throttling against a rate-limited endpoint that would reject a burst of concurrent calls. For a large ids array, Promise.all can hit that second problem by itself, so cap concurrency with a small pool or a library like p-limit.
Why async forEach doesn’t wait
forEach calls its callback for every element and throws away whatever the callback returns, promise included:
| |
"done" prints before any user name. forEach starts all three async callbacks and returns without waiting on any of them, and you can’t await it because it returns undefined, not a promise. For sequential work, use for...of with await inside. For concurrent work, use the same fix as the loop above:
| |
This works because map returns an array of promises, which is exactly what Promise.all takes.
Why an unhandled promise rejection crashes Node
An async function that throws produces a rejected promise, not an exception in the caller’s stack frame:
| |
In a browser tab this logs Uncaught (in promise) to the console and the page keeps running. Node is stricter: since Node 15, an unhandled rejection is treated like an uncaught exception by default, and the process exits. Handle rejections where they happen, with try/catch around the await or a .catch() on the call. Then, as a last-resort net, add a global handler:
| |
Registering this listener replaces Node’s default crash, so the process now keeps running after a rejection it knows nothing about. Use it to log and then shut down cleanly (call process.exit(1) after flushing logs), not to paper over missing handlers.
Why try/catch doesn’t catch errors in callbacks
A try/catch only sees exceptions thrown while its block is running. Code scheduled for later, in another task or microtask, is out of reach even if it’s written on the next line:
| |
setTimeout schedules the callback and returns, so the try block finishes immediately. When the callback throws 100ms later, it runs on a fresh call stack with no catch above it.
await is different. The async function is suspended, not finished: its local state and its try block are saved, and when the awaited promise rejects, the function resumes by throwing the rejection at the await line, inside the try:
| |
Note the return await. With a bare return getUser(id), the function hands the promise straight back without waiting on it, and a rejection skips this catch entirely.
How to catch these bugs before they ship
Most of these mistakes are detectable statically, so let the linter find them instead of the event loop. In ESLint, turn on no-await-in-loop to flag sequential awaits (then disable it inline where the sequencing is deliberate) and no-async-promise-executor. If you use TypeScript, add @typescript-eslint/no-floating-promises and @typescript-eslint/no-misused-promises: the first catches promises that are neither awaited nor handled, the second catches async callbacks passed to places like forEach that ignore the returned promise. When output still comes out in an order you didn’t expect, write down which queue each callback lands in before touching the code.