Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~33 min· topic 7 of 8

Topic 15.7

Interview Deep Dive: The Event Loop and Promises

In one line

Async interview questions test one model: synchronous code runs to the end, then every microtask (promise callbacks, await continuations, queueMicrotask) runs, then one task (a timer, an event) runs, and the cycle repeats; with that model you can predict any output order and write Promise.all and debounce from scratch.

Think of it like this

A doctor's clinic. The doctor (the call stack) sees one patient at a time and never stops halfway. Patients who walked in wait in the waiting room (the task queue: timers, clicks, network replies). But the doctor also has a short "quick questions" tray (the microtask queue): after each patient, the doctor answers every note in the tray, including new notes added while answering, before calling the next patient. Promise callbacks are notes in the tray; setTimeout puts a patient in the waiting room.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Call stack
The list of functions currently running; JavaScript finishes the top one before anything else runs.
Task (macrotask)
A unit of work the event loop runs one at a time: a timer callback, an event handler, a network reply.
Microtask
A small job (promise callback, code after await, queueMicrotask) that runs as soon as the stack is empty, before the next task.
Event loop
The browser's loop that runs a task, then all microtasks, then maybe renders, then the next task, forever.
Drain
Run every item in a queue until it is empty, including items added along the way.
Polyfill
Your own code that does what a built-in feature does, for older environments or for practice.
Debounce
Wait until calls stop for a while, then run the function once.
Throttle
Run the function at most once per time window, however often it is called.
Unhandled rejection
A promise that failed with nobody listening for the error with catch or try/catch.

Step by step

01Draw the three boxes

On paper, draw Stack, Microtasks and Tasks. Every async question is just moving items between these boxes in the right order.

Draw the three boxesdiagram
Rendering diagram…

02Walk the synchronous code first

Log everything synchronous in order. Remember the two traps: promise executors run now, and async functions run now until their first await.

03Queue as you read

setTimeout adds to Tasks. .then on a resolved promise, queueMicrotask and the rest of an async function after await add to Microtasks. Write them in the order they were added.

Queue as you readdiagram
Rendering diagram…

04Drain, then one task, then drain again

When the stack empties, run microtasks until there are none left, even new ones. Only then run the first task, and drain microtasks again after it.

05Write Promise.all step by step

Return a new promise. Convert the input to an array, handle the empty case, wrap each item in Promise.resolve, store each result at its index, count down, and pass reject straight through.

promise-all.jswhole filejavascript
function promiseAll(items) {
  return new Promise((resolve, reject) => {
    const list = Array.from(items);
    const results = new Array(list.length);
    let left = list.length;
    if (left === 0) return resolve([]);
    list.forEach((item, i) => {
      Promise.resolve(item).then((value) => {
        results[i] = value;
        if (--left === 0) resolve(results);
      }, reject);
    });
  });
}

06Write debounce step by step

Keep the timer id in a closure. Every call clears it and starts a new timer. Use a regular function so you can forward this and the arguments.

debounce.jswhole filejavascript
function debounce(fn, ms) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), ms);
  };
}

Try it yourself

  1. 1

    Add a nested timer

    In puzzle 1, inside the then A callback add setTimeout(() => console.log("inner timeout"), 0);. Predict whether it prints before or after timeout, then run.

  2. 2

    Add an await

    In puzzle 2, add await null; as the first line of second. Predict which lines move. Remember second now pauses too, and first waits for it.

  3. 3

    Write allSettled

    Copy promiseAll and change it into promiseAllSettled: never reject, and store { status: "fulfilled", value } or { status: "rejected", reason } at each index. Test it with the boom example.

Code & diagrams

Puzzle 1: sync, microtasks, then tasks New tab

`then B` is queued only when `then A` runs, so it lands after `queueMicrotask`, which was already waiting. The timer waits for every microtask.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

sync start
sync end
then A
queueMicrotask
then B
timeout
Puzzle 2: async/await and the promise executor ES2017+ New tab

`first` runs synchronously until `await`, and `second` runs completely (it has no `await`). The executor of `new Promise` is synchronous too. The continuation after `await` was queued before `then 1`, so it runs first.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

script start
first start
second
promise executor
script end
first end
then 1
then 2
setTimeout
Puzzle 3: interleaved chains and a rejection New tab

Each `.then` waits one tick for the previous step. A thrown error skips `then` handlers until a `catch`; skipping a handler still costs a tick, which is why `caught` appears after `A3`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

A1
B1
try/catch: async failure
A2
A3
caught: B2 failed
finally
Write Promise.all, then compare with the real one New tab

Results keep the input order even though `fast` finished first. The plain value `42` works because of `Promise.resolve`. The rejection arrives at 20 ms without waiting for `ok`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

empty: []
rejected: boom
all: [ 'slow', 'fast', 42 ]
native: [ 'slow', 'fast', 42 ]
Debounce: only the last call in a burst runs New tab

The call at 50 ms restarted the 100 ms timer, so `java` ran at 150 ms and the first three calls never ran. Now type quickly in the box: the text updates only after you pause.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

search: java
search: javascript

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Store results with push

In promiseAll, replace results[i] = value; with results.push(value); (and start with const results = [];).

terminal
$ Console
── what you'll see ──
all: [ 42, 'fast', 'slow' ]

Break #2

Forget to handle a rejection

In puzzle 3, delete the .catch(...) line.

terminal
$ Console
── what you'll see ──
Uncaught (in promise) Error: B2 failed

Myth vs fact

Myth

setTimeout(fn, 0) runs fn immediately after the current line.

Fact

It queues a task. All remaining synchronous code and all microtasks run first, and the browser may render in between.

Myth

await blocks the whole page until the promise settles.

Fact

It pauses only that async function. The stack unwinds and the event loop keeps running other code, events and rendering.

Myth

Promises make code run in parallel.

Fact

JavaScript on the page still runs on one thread. Promises coordinate waiting; the waiting itself (network, timers) happens in the browser, and work only runs in parallel in Web Workers.

Myth

array.forEach(async (x) => await save(x)) saves items one by one and waits.

Fact

forEach ignores the returned promises; it starts all saves and returns at once. Use for...of with await for sequence, or Promise.all(array.map(save)) for parallel.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Microtask starvation: a microtask that keeps queueing microtasks (function loop() { queueMicrotask(loop); }) never lets the loop reach a task or a render, so the page freezes just like an infinite while loop.

  • ▸

    Since ES2019 await of a native promise costs one tick (the spec change "await optimization"). Awaiting a non-native thenable still costs extra ticks because its then is called in a separate job. return await p inside try matters for catching; outside try it is only a stack-trace difference.

  • ▸

    Browsers schedule rendering about once per display refresh, so a frame typically fits several tasks. requestAnimationFrame callbacks run just before style and layout; scheduler.postTask (Chromium) adds task priorities (user-blocking, user-visible, background).

  • ▸

    Node.js has extra queues: process.nextTick callbacks run before promise microtasks, and setImmediate runs in the "check" phase after I/O. That is why some output-order answers differ between Node and the browser.

Remember this

  1. 1

    The pieces. The call stack runs your synchronous code, one function on top of another; nothing else in the page's JavaScript can run until it is empty. Tasks (also called macrotasks) come from setTimeout, setInterval, events, MessageChannel and network callbacks; the browser runs one task per turn of the loop. Microtasks come from promise reactions (then, catch, finally), the code after await, queueMicrotask and MutationObserver. After every task (and after the main script, which is itself a task), the engine drains the whole microtask queue. Then the browser may render (style, layout, paint, with requestAnimationFrame callbacks just before), then takes the next task. Phase 10 (Topic 10.3) introduced this; here you use it under pressure.

  2. 2

    The prediction recipe. (1) Run all synchronous code, writing logs in order. Note that a new Promise(executor) runs its executor synchronously, and an async function runs synchronously **until its first await**. (2) Keep two lists while reading: microtasks and tasks, in the order they were queued. A .then on an already-resolved promise queues its callback at once; on a pending promise, it queues when the promise settles. (3) When the stack is empty, run microtasks one by one; any new microtask they create joins the end of the same list and still runs before any task. (4) Run the first task, then drain microtasks again. setTimeout(fn, 0) is never "now": at best it is the next task, and browsers clamp nested timers (after 5 levels) to at least 4 ms.

  3. 3

    **await in detail.** await x pauses the async function and schedules the rest of it as a microtask once x settles. For a native promise (or a plain value) the continuation takes one microtask tick since ES2019 (older engines took three, and some old interview answers still assume that). .then chains add one tick per step, so p.then(a).then(b) interleaves with other chains: tick 1 runs every chain's first step, tick 2 every chain's second step. An async function always returns a promise; return value resolves it and throw rejects it.

  4. 4

    Promise.all from scratch is the most common async coding question. Requirements: accept any iterable (not only arrays); wrap each item with Promise.resolve so plain values work; keep results in input order (store by index, not by push, because promises finish in any order); count completions and resolve when the count reaches the total; reject immediately with the first rejection; and resolve an empty input with [] straight away. Its siblings: allSettled (ES2020) never rejects and returns { status, value | reason } objects; race settles with the first to settle; any (ES2021) fulfils with the first success and rejects with an AggregateError only if all fail.

  5. 5

    Debounce and throttle control functions that fire too often (typing, scrolling, resizing). Debounce waits until calls stop for ms milliseconds, then runs once with the latest arguments: each call clears the previous timer and starts a new one. Use it for search-as-you-type and autosave. Throttle runs at most once per ms, ignoring (or deferring) calls in between: use it for scroll and pointer-move handlers. Both are closures holding a timer id or a timestamp. Interviewers often ask for options (leading call, cancel(), flush()), preserving this and arguments, and how you would test them (fake timers in Vitest or Jest, Topic 13.6).

  6. 6

    Errors in async code. A rejection with no handler causes an unhandledrejection event and a console message Uncaught (in promise) Error: .... Inside async functions use try/catch around await; for parallel work, start everything first and await Promise.all(...), because awaiting one by one in a loop makes the work sequential and slower. A forEach with an async callback doesn't wait for anything: use for...of with await (sequential) or Promise.all(items.map(async ...)) (parallel).

Explain it without notes

01

Explain the event loop, tasks and microtasks in your own words.

02

In what order do setTimeout, Promise.then, queueMicrotask and synchronous logs run, and why?

03

Implement Promise.all and list the edge cases you handled.

04

What is the difference between debounce and throttle? When would you use each?

Practice

01

Predict the output: setTimeout(() => console.log(1)); Promise.resolve().then(() => { console.log(2); setTimeout(() => console.log(3)); }); console.log(4);

02

Write promiseRace(items) that settles like the first item to settle.

03

Write throttle(fn, ms) that runs fn at most once every ms milliseconds (leading call only).

04

Write sleep(ms) and use it in an async function that prints a, waits 100 ms, then prints b.

Trade-offs

  • ↔

    Debounce saves the most work but delays feedback until the user pauses; throttle gives steady feedback but runs more often. Search boxes often combine a short debounce with cancelling stale requests (AbortController, Topic 11.2).

  • ↔

    Promise.all fails fast, which is right when every result is required; allSettled is better when partial results are useful, at the cost of checking each status.

Done when you can

  • I can predict output order for any mix of sync code, promises, await, queueMicrotask and timers.

  • I know an executor and an async function's first part run synchronously.

  • I can write Promise.all, allSettled and race from scratch, with edge cases.

  • I can write debounce and throttle and say when to use each.

  • I can avoid unhandled rejections and accidental sequential awaits.