JavaScript async/await, one step at a time
Guess the output order of the code below, then step through what the engine actually does: the call stack, the microtask queue and the timer queue.
console.log("1: script start")
setTimeout(() => console.log("6: timeout"), 0)
async function load() {
console.log("2: load() starts")
const data = await fetchData()
console.log("4: got", data)
}
function fetchData() {
return Promise.resolve("data")
}
load()
Promise.resolve().then(() => console.log("5: then callback"))
console.log("3: script end")
Call stack
- script
Microtask queue (promises, await)
- empty
Macrotask queue (timers, events)
- empty
Console
1: script start
Step 1/13. The whole file runs as one task. The first log prints immediately.
The three rules behind async/await
- An async function runs synchronously until its first
await. Calling it doesn’t defer anything by itself. awaitpauses only that function. The rest of the function is scheduled as a microtask once the awaited promise settles, and control goes back to the caller.- Microtasks before macrotasks. When the stack is empty, the event loop runs every queued microtask (promise callbacks, await continuations) before the next macrotask (timers, I/O, UI events).
These rules come from the HTML Standard’s event loop processing model and the ECMAScript spec’s job queues; browsers and Node.js follow them the same way for promises.
Patterns you’ll use daily
const wait = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms))
console.time("sequential")
await wait(200, "a"); await wait(200, "b")
console.timeEnd("sequential")
console.time("parallel")
await Promise.all([wait(200, "a"), wait(200, "b")])
console.timeEnd("parallel")▶ Runasync function risky() {
throw new Error("network down")
}
try {
await risky()
} catch (err) {
console.log("caught:", err.message)
} finally {
console.log("cleanup runs either way")
}▶ Runconst ids = [1, 2, 3]
const fetchUser = async (id) => ({ id, name: "user" + id })
// one at a time (order matters, or rate limits):
for (const id of ids) console.log(await fetchUser(id))
// all at once:
console.log(await Promise.all(ids.map(fetchUser)))▶ Runconst timeout = (p, ms) => Promise.race([
p,
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out")), ms)),
])
const slow = new Promise((r) => setTimeout(() => r("done"), 500))
try {
console.log(await timeout(slow, 100))
} catch (e) {
console.log(e.message)
}▶ RunCommon mistakes
- Forgetting
await: you get a pending Promise instead of the value, and errors go unhandled. forEachwith an async callback:forEachdoesn’t wait for promises. Usefor…oforPromise.all(arr.map(…)).- Accidental serial awaits: independent requests awaited one by one take the sum of their times instead of the longest one.
Syntax reference: JavaScript cheat sheet. Try your own code in the JavaScript playground.
Frequently asked questions
What does async do in JavaScript?
Marking a function async makes it always return a Promise and lets you use await inside it. A returned value becomes the resolved value; a thrown error becomes a rejection.
Does await block the whole program?
No. await pauses only the async function it is in. Control returns to the caller, and the rest of the program keeps running; the function resumes later as a microtask when the awaited promise settles.
How do I run several awaits in parallel?
Start the promises first and await them together: const [a, b] = await Promise.all([getA(), getB()]). Writing await getA(); await getB(); runs them one after the other.
How do I handle errors with async/await?
Wrap awaits in try/catch, or attach .catch() to the promise returned by the async function. Unhandled rejections are reported by the browser or Node as errors.
Why does setTimeout(fn, 0) run after promise callbacks?
Timer callbacks are macrotasks; promise callbacks and await continuations are microtasks. After each macrotask, the event loop empties the whole microtask queue before it takes the next macrotask.