Explain the JavaScript event loop
The Short Answer
The event loop is the mechanism that allows JavaScript to perform non-blocking operations despite being single-threaded. It continuously checks whether the call stack is empty, and if so, picks the next task from the queue and pushes it onto the stack for execution. This is how JavaScript handles async operations like timers, network requests, and user events without freezing the UI.
The Key Players
To understand the event loop, you need to know the four components that work together. Each has a specific role in how JavaScript schedules and executes code.
Call Stack
The call stack is where JavaScript keeps track of what function is currently executing. It's a LIFO (last in, first out) structure — when a function is called, it's pushed onto the stack; when it returns, it's popped off. JavaScript can only do one thing at a time because there's only one call stack.
Web APIs / Node APIs
When you call setTimeout, fetch, or add an event listener, the browser (or Node.js) handles the actual waiting in a separate thread. JavaScript hands off the work and continues executing. When the timer expires or the response arrives, the callback gets placed in a queue.
Task Queue (Macrotask Queue)
Callbacks from setTimeout, setInterval, DOM events, and I/O operations land here. The event loop processes one task from this queue per iteration, but only after the call stack is empty and all microtasks have been processed.
Microtask Queue
Promise callbacks (.then, .catch, .finally), queueMicrotask(), and MutationObserver callbacks go here. Microtasks have higher priority — the entire microtask queue is drained before the event loop picks up the next macrotask.
The Event Loop Algorithm
The event loop follows a specific sequence on every iteration. Understanding this order is the key to predicting the output of async code.
Execute synchronous code
Run everything on the call stack until it's empty. This includes the current script and any synchronous function calls.
Drain the microtask queue
Process ALL pending microtasks (Promise callbacks, queueMicrotask). If a microtask schedules another microtask, that one runs too — the queue must be completely empty before moving on.
Render (if needed)
The browser may update the UI at this point (typically targeting 60fps). This includes style calculations, layout, and paint.
Pick one macrotask
Take the oldest task from the macrotask queue (setTimeout callback, event handler, etc.) and execute it. Then go back to step 1.
Predicting Execution Order
This is the classic interview question. The code below mixes synchronous code, Promises (microtasks), and setTimeout (macrotask). Before reading the answer, try to predict the output order based on the event loop algorithm above.
console.log('1 - sync');
setTimeout(() => {
console.log('2 - setTimeout');
}, 0);
Promise.resolve()
.then(() => console.log('3 - promise 1'))
.then(() => console.log('4 - promise 2'));
console.log('5 - sync');
// Output:
// 1 - sync
// 5 - sync
// 3 - promise 1
// 4 - promise 2
// 2 - setTimeout
Here's why: synchronous code runs first (1, 5). Then the microtask queue is drained — both Promise callbacks run (3, 4). Only after all microtasks are done does the event loop pick up the setTimeout macrotask (2). Even though setTimeout has a 0ms delay, it still goes through the macrotask queue and waits for microtasks to finish.
A More Complex Example
This example nests microtasks inside macrotasks and vice versa. The key insight is that after each macrotask completes, the entire microtask queue is drained before the next macrotask runs. This means a Promise inside a setTimeout runs before the next setTimeout.
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => console.log('promise inside timeout 1'));
}, 0);
setTimeout(() => {
console.log('timeout 2');
}, 0);
Promise.resolve().then(() => {
console.log('promise 1');
setTimeout(() => console.log('timeout inside promise'), 0);
});
// Output:
// promise 1 (microtask — runs before any macrotask)
// timeout 1 (first macrotask)
// promise inside timeout 1 (microtask created during timeout 1 — drains before next macro)
// timeout 2 (second macrotask)
// timeout inside promise (third macrotask — scheduled by the earlier promise)
The golden rule
After every single macrotask, the entire microtask queue is drained. Microtasks always cut in line ahead of the next macrotask. This is why Promises resolve before setTimeout callbacks, even with a 0ms delay.
Why setTimeout(fn, 0) Isn't Instant
setTimeout(fn, 0) doesn't mean "run immediately." It means "schedule this as a macrotask to run as soon as possible." But "as soon as possible" means after the current synchronous code finishes, after all microtasks drain, and after the browser has had a chance to render. In practice, browsers also enforce a minimum delay of ~4ms for nested timeouts.
Common Mistakes
Blocking the event loop with heavy computation
A long-running synchronous operation (e.g., processing a huge array in a loop) blocks the call stack. No events, no rendering, no callbacks can run until it finishes — the UI freezes completely.
✅Break heavy work into chunks using `setTimeout` or `requestAnimationFrame`, or offload to a Web Worker.
Infinite microtask loops
If a microtask schedules another microtask endlessly (e.g., a `.then()` that resolves another Promise in a loop), the microtask queue never empties. The event loop is stuck — no macrotasks run, no rendering happens.
✅Ensure recursive microtask chains have a termination condition. Use `setTimeout` to break out to the macrotask queue if needed.
Why Interviewers Ask This
The event loop is the foundation of JavaScript's concurrency model. Interviewers ask this to verify you understand why JavaScript is non-blocking despite being single-threaded, can predict execution order of mixed sync/async code, know the difference between microtasks and macrotasks, and can identify code that might block the main thread. It's one of the most fundamental concepts for writing performant, bug-free async JavaScript.
Quick Revision Cheat Sheet
Single-threaded: One call stack — JS can only execute one thing at a time
Microtasks: Promises, queueMicrotask — drain completely after each task
Macrotasks: setTimeout, setInterval, DOM events — one per loop iteration
Priority: Sync code → all microtasks → render → one macrotask → repeat
setTimeout(fn, 0): Not instant — waits for sync code + all microtasks + render