Async, Promises & the Event Loop

Roughly 20% of a typical loop — and 20% of the mock exam here.

One rule explains most ordering puzzles

The engine runs one task to completion, then drains the entire microtask queue, then renders, then takes the next task. Promise handlers, await continuations and queueMicrotask callbacks are microtasks; timers, I/O callbacks and events are tasks.

setTimeout(() => console.log('a'), 0);
Promise.resolve().then(() => console.log('b'));
console.log('c');
// c, b, a

Two corollaries interviewers probe. First, everything in an async function before its first await runs synchronously, so a validation throw up there still rejects rather than throwing at the call site. Second, await yields to the microtask queue even when its operand is a plain value, so await 42 still defers the rest of the function.

A runaway microtask loop is worse than a slow task: the queue must empty before the next task or paint, so the page hangs with no long frame in the profiler. In Node, process.nextTick has its own queue that drains before promise microtasks once a task callback returns, and recursive nextTick can starve both promises and I/O. The one place that ordering flips is the top level of an ES module, whose evaluation already runs inside a microtask drain — verify with a timer callback, not with a bare script, if you ever have to demonstrate it.

Promises: eager, single-settlement, uncancellable

The executor passed to new Promise runs synchronously — the work starts at construction, not at await. A promise settles once and never changes, which is why promises cannot be cancelled; you cancel the underlying work instead.

then returns a new promise that settles with whatever the handler returns or throws. Returning a promise from inside a handler adopts it, which keeps chains flat — and forgetting to return it is the classic bug where the chain continues before the work finishes.

p.then(onOk, onErr) does not catch errors thrown inside onOk; p.then(onOk).catch(onErr) does. Prefer the trailing .catch. .finally runs either way, receives no argument and passes the outcome through, but a throw inside it replaces that outcome.

Combinators, in the order they come up:

  • Promise.all — rejects on the first rejection; the others keep running uncancelled.
  • Promise.allSettled — never rejects; one {status, value} or {status, reason} per input. The right tool when you need every result plus a failure report.
  • Promise.race — settles with the first settled input, fulfilled or rejected. It does not cancel the loser, so pair a fetch race with an AbortController.
  • Promise.any — first fulfillment wins; rejects with an AggregateError only if all inputs reject.

Non-promise entries are passed through Promise.resolve, so plain values and any thenable work.

async/await in practice

Two sequential awaits over independent work cost the sum of both. Start them first, then await together:

const [a, b] = await Promise.all([getA(), getB()]);

forEach ignores the promise its callback returns, so items.forEach(async (i) => await save(i)) never waits. Use for (const i of items) await save(i) for sequential work, or await Promise.all(items.map(save)) for parallel.

Remember that promises are eager: urls.map(u => fetch(u)) starts every request immediately, so awaiting them in a loop does not limit concurrency. To bound it, map to functions and run them through a pool.

return await work() inside try/finally is the one case where the redundant-await lint is wrong — without the await, the function leaves the stack before work settles, so finally and catch never see it.

Failures, timers and cancellation

fetch fulfills on 404 and 500; only a network failure or CORS block rejects. Check response.ok before parsing, or an error page flows into .json() as data.

An unhandled rejection terminates a Node process (15 and later) and fires unhandledrejection in browsers, so attach handlers in the same tick the promise is created. setInterval queues its next run regardless of callback duration and drifts; recursive setTimeout schedules after the previous run finishes. And sleep is just new Promise((r) => setTimeout(r, ms)) — there is no built-in.

Sample questions

6 of the 40 questions this domain carries in practice mode — expand one to check yourself before drilling.

1. A promise can be in which states, and how often may it change?
  1. pending, fulfilled or rejected — it settles once and its state never changes again
  2. pending, running or done, cycling between them as each handler in the chain runs
  3. open or closed, with the value only readable while the promise is still open
  4. waiting, resolved or cancelled, and calling cancel() moves a settled promise back to waiting

Answer: A. Settling is permanent, which is why a promise cannot be cancelled — you cancel the underlying work instead, usually with an AbortController.

2. An async function always returns:
  1. whatever its body returns, wrapped in a promise only when the body contains an await
  2. a generator object, since async functions are implemented on top of generators
  3. undefined until the returned value is read through await by the caller
  4. a promise, even when the body is fully synchronous and returns a plain value

Answer: D. A thrown error becomes a rejected promise rather than a synchronous throw — which is why forgetting await hides failures entirely.

3. setTimeout(() => console.log('a'), 0); Promise.resolve().then(() => console.log('b')); console.log('c'); prints:
  1. b, c, a — promise handlers preempt synchronous code as soon as they are ready
  2. c, a, b, because timers were queued before the promise handler was attached
  3. a, b, c, since both callbacks are scheduled ahead of the remaining synchronous line
  4. c, b, a — synchronous code first, then the microtask queue, then the timer task

Answer: D. The microtask queue drains completely between tasks, so every pending then runs before the next timer callback gets a turn.

4. const a = await getA(); const b = await getB(); takes 2 s when each call takes 1 s. The fix is:
  1. removing the awaits entirely, since promises resolve concurrently without them anyway
  2. wrapping both calls in queueMicrotask so the engine may interleave their execution
  3. const [a, b] = await Promise.all([getA(), getB()]), starting both before awaiting either
  4. awaiting inside a for loop, which lets the runtime batch the two requests into one round trip

Answer: C. The sequential form is right when b depends on a. When they are independent, starting both first cuts wall-clock time to the slower one.

5. async function f() { console.log(1); await null; console.log(3); } f(); console.log(2); prints:
  1. 1, 2, 3 only when the awaited value is a promise; awaiting null logs 1, 3, 2 instead
  2. 1, 3, 2, since awaiting null does not actually suspend the function at all
  3. 2, 1, 3, because calling an async function schedules its whole body as a task
  4. 1, 2, 3 — the body runs synchronously up to the await, then resumes as a microtask

Answer: D. Everything before the first await is synchronous — the reason a validation throw before any await still rejects rather than throwing at the call site.

6. Inside a Node timer or I/O callback, process.nextTick relates to promise microtasks how?
  1. promise microtasks run first, and nextTick callbacks run only when that queue is empty
  2. they share one queue, so callbacks run strictly in the order they were scheduled
  3. the nextTick queue drains completely before the promise microtask queue once that callback returns
  4. nextTick callbacks are ordinary timers, so they run during the timers phase after the current task ends

Answer: C. Recursive nextTick can starve promises and I/O. The order flips at ESM top level, where module evaluation is already inside a microtask drain.

Drill this domain in practice mode →

Independent community study resource — not affiliated with or endorsed by Oracle, Microsoft or Ecma International. JavaScript is a trademark of Oracle Corporation; TypeScript is a trademark of Microsoft Corporation. All questions and study notes are original, written from MDN, the ECMAScript specification and the TypeScript handbook. Everything runs in your browser; nothing you answer is stored or transmitted.