Run this in Node or a browser console:

1
2
3
4
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

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 setTimeout and setInterval callbacks, 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 with queueMicrotask. The event loop drains this queue completely, including microtasks added while draining it, before moving on.

One iteration of the loop looks like this:

1
2
3
4
5
6
run all synchronous code until the call stack is empty
  -> drain the entire microtask queue (new microtasks jump the same line)
    -> run exactly one task from the task queue
      -> drain the microtask queue again
        -> (browser only) repaint if needed
          -> repeat

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.

1
2
3
4
5
6
7
8
9
async function example() {
  console.log("a");
  await null;
  console.log("b");
}

console.log("start");
example();
console.log("end");

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function printUser(id) {
  const user = getUser(id); // missing await
  console.log(user.name);
}

printUser(4821); // "undefined" — user is a Promise, not the resolved object

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:

1
2
3
4
5
6
7
async function loadUsers(ids) {
  const users = [];
  for (const id of ids) {
    users.push(await getUser(id)); // one request at a time
  }
  return users;
}

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:

1
2
3
async function loadUsers(ids) {
  return Promise.all(ids.map((id) => getUser(id)));
}

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:

1
2
3
4
5
6
7
function printAll(ids) {
  ids.forEach(async (id) => {
    const user = await getUser(id);
    console.log(user.name);
  });
  console.log("done");
}

"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:

1
2
3
4
async function printAll(ids) {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  users.forEach((user) => console.log(user.name));
}

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:

1
2
3
4
5
async function risky() {
  throw new Error("boom");
}

risky(); // called with no .catch and no surrounding try/catch

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:

1
2
3
process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection:", reason);
});

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:

1
2
3
4
5
6
7
try {
  setTimeout(() => {
    throw new Error("late");
  }, 100);
} catch (err) {
  console.log("caught:", err.message); // never runs
}

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:

1
2
3
4
5
6
7
8
async function loadUser(id) {
  try {
    return await getUser(id);
  } catch (err) {
    console.error(`failed to load user ${id}:`, err.message);
    throw err;
  }
}

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.