JavaScript runs your code on a single thread, yet a page can fetch data, run timers and respond to clicks at the same time. The event loop is the scheduler that makes this work, and a “predict the output” question built from a few promises and timeouts is the standard way interviewers check whether you understand it. The rules are short. This note states them precisely, applies them to progressively harder snippets, and ends with the failure mode most candidates never mention: starving the loop.
The moving parts
- Call stack. Where synchronous code runs. A call pushes a frame, a return pops it. JavaScript runs to completion: nothing else can execute until the stack is empty.
- Task queue. Callbacks for timers (
setTimeout,setInterval), I/O, UI events like clicks, andpostMessage/MessageChannelmessages. Running the initial script is itself a task. Browsers keep several task queues and may prioritize some (user input, for instance), but within one queue tasks run in the order they were added. - Microtask queue. Promise reactions (
then,catch,finallycallbacks, and the code after anawait),queueMicrotaskcallbacks, andMutationObservercallbacks. - The host. Timers and network requests are tracked by the browser (or libuv in Node) outside JavaScript. When one is ready, the host queues a task.
“Macrotask” is informal vocabulary; the HTML spec just says task. Interviewers use both, so it is worth knowing they mean the same thing.
setTimeout(() => console.log('timeout'), 0);
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('then'));
console.log('sync');
// → sync, queueMicrotask, then, timeoutsetTimeout(fn, 0) means “queue a task after at least 0 ms”, not “run now”. Browsers also clamp deeply nested timers to a minimum of about 4 ms.
One turn of the loop
- Take the oldest task from a task queue and run it until the call stack is empty.
- Run microtasks until the microtask queue is empty, including microtasks queued while draining.
- If it is time to update the screen (typically once per display frame, about 16.7 ms at 60 Hz), run
requestAnimationFramecallbacks, then recalculate style and layout, then paint. - Repeat.
The microtask drain in step 2 is not limited to the end of a task. It happens whenever the call stack empties after a callback, for example between the individual requestAnimationFrame callbacks in step 3.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
// → A D C BThree consequences come up constantly. A zero-delay timer always runs after every microtask that is already queued, and after the current script, which is why the classic setTimeout loop logs only once the loop is done. A long-running task blocks both input handling and rendering. And requestAnimationFrame is the right place for visual updates because it runs just before paint; its ordering relative to a setTimeout depends on where in the frame you are, so never rely on it.
Interview tip
Say it out loud: “synchronous code first, then all microtasks, then one macrotask.” Then add “the browser may render between tasks” to show you know where paint fits.
User clicks vs .click()
That “whenever the stack empties” rule produces the most subtle ordering in the browser. Take a button with two listeners:
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 1'));
console.log('listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 2'));
console.log('listener 2');
});
// Real user click: listener 1, microtask 1, listener 2, microtask 2
// button.click(): listener 1, listener 2, microtask 1, microtask 2On a real click the browser calls each listener from an empty stack, so microtasks drain in between. With button.click() your script is still on the stack while both listeners run, so the microtasks wait until it finishes.
async/await is microtasks in disguise
An async function runs synchronously until its first await. At that point it pauses, and the rest of the function is scheduled as a microtask once the awaited value settles. Even await null pauses.
async function main() {
console.log('1: before await');
await null;
console.log('3: after await');
}
main();
console.log('2: sync, after main()');
// → 1 2 3For scheduling purposes, awaiting a native promise behaves like attaching a then:
const getUser = () => Promise.resolve({ name: 'Ada' });
async function loadA() {
console.log('start A');
const user = await getUser();
console.log('got', user.name);
}
// Roughly what the engine does:
function loadB() {
console.log('start B');
return Promise.resolve(getUser()).then((user) => {
console.log('got', user.name);
});
}
loadA();
loadB();
console.log('sync end');
// → start A, start B, sync end, got Ada, got AdaThe real mechanism suspends and later resumes the same function call, locals intact, and turns thrown errors into a rejected promise. The timing matches the sketch, though: one microtask per await on a native promise.
Predict the output
Work each one out before reading the answer. All outputs below were produced by Node 22; browsers give the same results.
Puzzle 1: async functions and a chain
console.log('script start');
setTimeout(() => console.log('timeout'), 0);
async function foo() {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar() {
console.log('bar');
}
foo();
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('script end');Answer: script start, foo start, bar, script end, foo end, promise 1, promise 2, timeout. bar runs synchronously inside foo. The await queues “foo end” before “promise 1” is queued. “promise 2” is only queued once “promise 1” has run, and the timer waits for the whole drain.
Puzzle 2: timers and microtasks interleaved
setTimeout(() => console.log('T1'), 0);
setTimeout(() => {
console.log('T2');
Promise.resolve().then(() => console.log('M inside T2'));
}, 0);
setTimeout(() => console.log('T3'), 0);
Promise.resolve().then(() => {
console.log('M1');
setTimeout(() => console.log('T from M1'), 0);
});Answer: M1, T1, T2, M inside T2, T3, T from M1. Microtasks drain after each task, so “M inside T2” beats T3. The timer created inside M1 was scheduled after the other three, so it runs last. (Node has matched browsers here since version 11.)
Puzzle 3: the executor is synchronous
const p = new Promise((resolve) => {
console.log('executor');
resolve('value');
console.log('after resolve');
});
p.then((v) => console.log('then:', v));
queueMicrotask(() => console.log('microtask'));
console.log('sync');Answer: executor, after resolve, sync, then: value, microtask. The executor runs immediately, and resolve does not stop it. Calling then on an already-resolved promise queues the callback right away, before queueMicrotask runs.
Puzzle 4: returning a promise
async function returnsPromise() {
return Promise.resolve('async result');
}
returnsPromise().then(console.log);
Promise.resolve()
.then(() => console.log('tick 1'))
.then(() => console.log('tick 2'))
.then(() => console.log('tick 3'));Answer: tick 1, tick 2, async result, tick 3. Resolving the async function’s promise with another promise costs two extra microtasks: one to call that promise’s then, one for it to report back. Return a plain value instead and “async result” logs first.
Note
Node adds its own queue:
process.nextTickcallbacks. In a CommonJS script they run before promise callbacks. In an ES module the order flips, because the module body is itself evaluated asynchronously and the promise queue drains first. Don’t build logic on that difference; just recognize it.
Microtask starvation
Because the loop drains the microtask queue completely before it moves on, a microtask that always queues another one never lets it move on. No timers fire, no input is handled, nothing is painted. The page freezes exactly as if you had written while (true).
// Don't run this: the tab freezes
function forever() {
Promise.resolve().then(forever);
}
forever();A bounded version shows the mechanism safely. The timer was scheduled first, yet it waits for all million microtasks:
let count = 0;
setTimeout(() => console.log(`timeout ran after ${count} microtasks`), 0);
function spin() {
count += 1;
if (count < 1_000_000) queueMicrotask(spin); // a task can't get in between
}
spin();
// → timeout ran after 1000000 microtasksSwap queueMicrotask(spin) for setTimeout(spin, 0) and the log reports a count of 1: each setTimeout is a new task, so the waiting timer gets its turn after the first call. That is the principle behind splitting long work into chunks:
function processInChunks(items, handle, chunkSize = 1000) {
return new Promise((resolve) => {
let i = 0;
function runChunk() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) handle(items[i]);
if (i < items.length) setTimeout(runChunk, 0); // new task: input and paint can run
else resolve();
}
runChunk();
});
}Browsers clamp timers nested more than a few levels deep to about 4 ms, so production schedulers often yield through a MessageChannel message instead, which is also a task but without the clamp.
Gotcha
awaitinside a loop does not yield to the browser.for (...) { await null; work(); }resumes every iteration as a microtask, so it starves rendering just like thespinexample. To yield, await a task:await new Promise((r) => setTimeout(r, 0)), orscheduler.yield()where it is supported.
The interview answer
“JavaScript runs on one thread with a call stack, and asynchronous work comes back through queues. The task queue holds things like timers, I/O and UI events; the microtask queue holds promise callbacks, the continuation after an await, queueMicrotask and MutationObserver callbacks. The event loop takes one task, runs it to completion, then drains the microtask queue completely, including anything queued along the way. Only then can the browser run requestAnimationFrame callbacks, render, and pick the next task.
So for any output question: synchronous logs first, then microtasks in the order they were queued, then timers. An async function runs synchronously until its first await, and everything after it is a microtask. The flip side is starvation: an endless chain of microtasks blocks timers, input and paint, so for long work I yield with a task, such as setTimeout or scheduler.yield(), not a promise.”