Difficulty: Advanced
JavaScript is a single-threaded language, meaning it has one call stack and can execute one piece of code at a time. Yet it handles asynchronous operations like network requests, timers, and user interactions without blocking. This is possible because of the event loop - a mechanism that coordinates between the call stack, Web APIs (or Node.js APIs), and task queues to give the illusion of concurrency.
The call stack is a LIFO (Last In, First Out) data structure that tracks function execution. When you call a function, a frame is pushed onto the stack. When the function returns, its frame is popped off. If the stack is full (deep recursion), you get a "Maximum call stack size exceeded" error. The call stack must be empty before the event loop can pick up new tasks from the queues.
When you call an asynchronous API like setTimeout, fetch, or addEventListener, the browser's Web API layer (or Node.js's libuv) handles the actual waiting. The callback you provide is not placed on the call stack - instead, the Web API manages the timer or network request in the background, on a separate thread managed by the runtime. When the operation completes, the Web API pushes the callback into one of the task queues.
There are two types of task queues: the microtask queue and the macrotask (or simply "task") queue. Microtasks include Promise callbacks (.then, .catch, .finally), queueMicrotask(), and MutationObserver callbacks. Macrotasks include setTimeout, setInterval, setImmediate (Node.js), I/O callbacks, and UI rendering events. The critical rule is: after each macrotask completes, the engine drains the entire microtask queue before executing the next macrotask. This means microtasks have higher priority and can delay macrotasks and rendering if the microtask queue keeps growing.
The event loop's algorithm, simplified, works like this: (1) Execute the current script (the initial macrotask) until the call stack is empty. (2) Drain the entire microtask queue - run every microtask, and if a microtask adds new microtasks, run those too. (3) If needed, perform a render update (browser only, roughly every 16ms). (4) Pick the oldest macrotask from the macrotask queue and execute it. (5) Repeat from step 2. This explains why Promise.resolve().then(fn) always runs before setTimeout(fn, 0) - promise callbacks are microtasks and are processed before the next macrotask.
Output prediction questions are a staple of JavaScript interviews. They test your understanding of the event loop by mixing synchronous code, promise callbacks, and setTimeout calls, then asking you to predict the execution order. The key to solving them is to identify what runs synchronously (immediately), what goes into the microtask queue (promise handlers), and what goes into the macrotask queue (setTimeout handlers), then apply the priority rules: sync first, then all microtasks, then one macrotask, then all microtasks again, and so on.
console.log('1: Script start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
Promise.resolve()
.then(() => {
console.log('3: Promise 1');
})
.then(() => {
console.log('4: Promise 2');
});
console.log('5: Script end');
Synchronous code (1, 5) runs first. Then the microtask queue is drained: Promise 1 and Promise 2 run (microtasks). Finally, the setTimeout callback runs as the next macrotask. Even though setTimeout has 0ms delay, it always runs after all microtasks.
console.log('Start');
setTimeout(() => console.log('Timeout 1'), 0);
setTimeout(() => console.log('Timeout 2'), 0);
Promise.resolve()
.then(() => {
console.log('Promise 1');
// Schedule a new microtask from within a microtask
Promise.resolve().then(() => {
console.log('Promise 3 (nested)');
});
})
.then(() => {
console.log('Promise 2');
});
Promise.resolve().then(() => {
console.log('Promise 4');
});
console.log('End');
After synchronous code (Start, End), the microtask queue runs: Promise 1 and Promise 4 (both scheduled during sync execution). Promise 1's handler schedules Promise 3 (nested) - it runs before Promise 2 because it was added to the microtask queue during the current drain cycle. Promise 2 runs next (from the first chain). Only after ALL microtasks are done do the setTimeout callbacks execute.
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
}, 0);
Promise.resolve().then(() => {
console.log('D');
setTimeout(() => console.log('E'), 0);
});
setTimeout(() => {
console.log('F');
}, 0);
Promise.resolve().then(() => console.log('G'));
console.log('H');
Step by step: Sync runs A, H. Microtask queue: D then G (in order they were scheduled). Macrotask queue now has: setTimeout-B, setTimeout-F. Run setTimeout-B: prints B, schedules microtask C. Drain microtasks: C runs. Run setTimeout-F: prints F. D's handler also scheduled setTimeout-E, which is the last macrotask: E runs.
function multiply(a, b) {
return a * b; // stack: [main, calculate, multiply]
}
function calculate() {
const result = multiply(3, 4); // stack: [main, calculate]
console.log('Result:', result);
return result;
}
function main() {
console.log('Starting'); // stack: [main]
calculate(); // stack: [main, calculate]
console.log('Done'); // stack: [main]
}
main();
// stack: [] (empty)
// What happens with async:
console.log('Sync 1');
setTimeout(() => {
// This callback sits in the macrotask queue
// until the call stack is completely empty
console.log('Async (macrotask)');
}, 0);
queueMicrotask(() => {
console.log('Async (microtask)');
});
console.log('Sync 2');
The call stack grows as functions call each other and shrinks as they return. After all synchronous code finishes and the stack empties, the event loop checks the microtask queue first (queueMicrotask), then the macrotask queue (setTimeout). The stack comments show the call stack state at each point.
event loop, call stack, callback queue, microtask queue, macrotask queue, Web APIs, concurrency