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
catchortry/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.
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.
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.
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.
function debounce(fn, ms) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}Try it yourself
- 1
Add a nested timer
In puzzle 1, inside the
then Acallback addsetTimeout(() => console.log("inner timeout"), 0);. Predict whether it prints before or aftertimeout, then run. - 2
Add an await
In puzzle 2, add
await null;as the first line ofsecond. Predict which lines move. Remembersecondnow pauses too, andfirstwaits for it. - 3
Write allSettled
Copy
promiseAlland change it intopromiseAllSettled: never reject, and store{ status: "fulfilled", value }or{ status: "rejected", reason }at each index. Test it with theboomexample.
Code & diagrams
`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
Console
Nothing logged yet.Expected console output
sync start
sync end
then A
queueMicrotask
then B
timeout`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
Console
Nothing logged yet.Expected console output
script start
first start
second
promise executor
script end
first end
then 1
then 2
setTimeoutEach `.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
Console
Nothing logged yet.Expected console output
A1
B1
try/catch: async failure
A2
A3
caught: B2 failed
finallyResults 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
Console
Nothing logged yet.Expected console output
empty: []
rejected: boom
all: [ 'slow', 'fast', 42 ]
native: [ 'slow', 'fast', 42 ]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
Console
Nothing logged yet.Expected console output
search: java
search: javascriptBreak 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 = [];).
Break #2
Forget to handle a rejection
In puzzle 3, delete the .catch(...) line.
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 infinitewhileloop. - ▸
Since ES2019
awaitof a native promise costs one tick (the spec change "await optimization"). Awaiting a non-native thenable still costs extra ticks because itsthenis called in a separate job.return await pinsidetrymatters for catching; outsidetryit is only a stack-trace difference. - ▸
Browsers schedule rendering about once per display refresh, so a frame typically fits several tasks.
requestAnimationFramecallbacks run just before style and layout;scheduler.postTask(Chromium) adds task priorities (user-blocking,user-visible,background). - ▸
Node.js has extra queues:
process.nextTickcallbacks run before promise microtasks, andsetImmediateruns in the "check" phase after I/O. That is why some output-order answers differ between Node and the browser.
Remember this
- 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,MessageChanneland network callbacks; the browser runs one task per turn of the loop. Microtasks come from promise reactions (then,catch,finally), the code afterawait,queueMicrotaskandMutationObserver. 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, withrequestAnimationFramecallbacks just before), then takes the next task. Phase 10 (Topic 10.3) introduced this; here you use it under pressure. - 2
The prediction recipe. (1) Run all synchronous code, writing logs in order. Note that a
new Promise(executor)runs its executor synchronously, and anasyncfunction runs synchronously **until its firstawait**. (2) Keep two lists while reading: microtasks and tasks, in the order they were queued. A.thenon 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
**
awaitin detail.**await xpauses the async function and schedules the rest of it as a microtask oncexsettles. 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)..thenchains add one tick per step, sop.then(a).then(b)interleaves with other chains: tick 1 runs every chain's first step, tick 2 every chain's second step. Anasyncfunction always returns a promise;return valueresolves it andthrowrejects it. - 4
Promise.all from scratch is the most common async coding question. Requirements: accept any iterable (not only arrays); wrap each item with
Promise.resolveso plain values work; keep results in input order (store by index, not bypush, 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;racesettles with the first to settle;any(ES2021) fulfils with the first success and rejects with anAggregateErroronly if all fail. - 5
Debounce and throttle control functions that fire too often (typing, scrolling, resizing). Debounce waits until calls stop for
msmilliseconds, 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 perms, 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 (leadingcall,cancel(),flush()), preservingthisand arguments, and how you would test them (fake timers in Vitest or Jest, Topic 13.6). - 6
Errors in async code. A rejection with no handler causes an
unhandledrejectionevent and a console messageUncaught (in promise) Error: .... Insideasyncfunctions usetry/catcharoundawait; for parallel work, start everything first andawait Promise.all(...), becauseawaiting one by one in a loop makes the work sequential and slower. AforEachwith anasynccallback doesn't wait for anything: usefor...ofwithawait(sequential) orPromise.all(items.map(async ...))(parallel).
Explain it without notes
Explain the event loop, tasks and microtasks in your own words.
In what order do setTimeout, Promise.then, queueMicrotask and synchronous logs run, and why?
Implement Promise.all and list the edge cases you handled.
What is the difference between debounce and throttle? When would you use each?
Practice
Predict the output: setTimeout(() => console.log(1)); Promise.resolve().then(() => { console.log(2); setTimeout(() => console.log(3)); }); console.log(4);
Write promiseRace(items) that settles like the first item to settle.
Write throttle(fn, ms) that runs fn at most once every ms milliseconds (leading call only).
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.allfails fast, which is right when every result is required;allSettledis 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,queueMicrotaskand timers.I know an executor and an async function's first part run synchronously.
I can write
Promise.all,allSettledandracefrom scratch, with edge cases.I can write
debounceandthrottleand say when to use each.I can avoid unhandled rejections and accidental sequential awaits.