Skip to content

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.

step through the event loop
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

  1. script

Microtask queue (promises, await)

  1. empty

Macrotask queue (timers, events)

  1. empty

Console

1: script start

Step 1/13. The whole file runs as one task. The first log prints immediately.

▶ Run it for real

The three rules behind async/await

  1. An async function runs synchronously until its first await. Calling it doesn’t defer anything by itself.
  2. await pauses 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.
  3. 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

Sequential vs parallel
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")
▶ Run
Error handling
async function risky() {
  throw new Error("network down")
}

try {
  await risky()
} catch (err) {
  console.log("caught:", err.message)
} finally {
  console.log("cleanup runs either way")
}
▶ Run
await in a loop
const 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)))
▶ Run
Timeout a promise
const 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)
}
▶ Run

Common mistakes

  • Forgetting await: you get a pending Promise instead of the value, and errors go unhandled.
  • forEach with an async callback: forEach doesn’t wait for promises. Use for…of or Promise.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.