Event Loop
HardThe Event Loop is how JavaScript handles asynchronous operations despite being single-threaded. It continuously checks if the call stack is empty, then moves callbacks from the task queues to the stack for execution.
Interactive Visualization
1console.log('1');23setTimeout(() => {4 console.log('timeout');5}, 0);67Promise.resolve()8 .then(() => console.log('promise'));910console.log('2');
Understanding Event Loop
The JavaScript event loop is the mechanism that allows JavaScript to perform non-blocking asynchronous operations despite being a single-threaded language. Understanding the event loop is essential for writing performant JavaScript and is one of the most common topics in senior-level interviews.
JavaScript has a single call stack, meaning it can only execute one piece of code at a time. When you call a function, it gets pushed onto the stack. When it returns, it gets popped off. Synchronous code runs entirely on this stack, one function after another. But what happens when you need to wait for a network request, a timer, or user input? This is where the event loop comes in.
When you call an asynchronous API like setTimeout or fetch, the browser delegates that work to its Web APIs layer, which runs outside the JavaScript engine. The call stack continues executing the next line of code without waiting. When the async operation completes, its callback is placed into a task queue. The event loop continuously checks: is the call stack empty? If so, it takes the first callback from the queue and pushes it onto the stack for execution.
There are actually two types of queues with different priorities. The microtask queue handles Promise callbacks, queueMicrotask, and MutationObserver callbacks. The macrotask queue (also called the task queue) handles setTimeout, setInterval, and I/O callbacks. The critical rule is that all microtasks drain completely before the event loop processes the next macrotask. This is why a resolved Promise callback always executes before a setTimeout with a delay of zero — even though both are asynchronous.
This priority difference explains a subtle but important behavior. When you write setTimeout(fn, 0), you might expect fn to run immediately, but it does not. The zero-millisecond delay is a minimum, not a guarantee. The callback must wait for the current call stack to empty and for all pending microtasks to complete before it can run.
One danger to be aware of is microtask starvation. If a microtask keeps scheduling more microtasks recursively, the microtask queue never empties and macrotasks never get a chance to run. This effectively freezes the browser because rendering updates are also scheduled as tasks that the event loop must reach.
The event loop also controls when the browser can repaint the screen. Between processing tasks, the browser checks if a repaint is needed and runs requestAnimationFrame callbacks. Long-running synchronous code blocks the event loop and prevents repaints, which is why heavy computation should be offloaded to Web Workers or broken into smaller chunks using techniques like setTimeout chunking.
Key Points
- JavaScript is single-threaded (one call stack)
- Web APIs handle async operations (setTimeout, fetch, etc.)
- Task Queue (Macrotasks): setTimeout, setInterval, I/O
- Microtask Queue: Promises, queueMicrotask, MutationObserver
- Microtasks run before the next macrotask
- Event loop: Stack empty? → Run all microtasks → Run one macrotask → Repeat
Code Examples
Promise vs setTimeout
console.log("1"); setTimeout(() => { console.log("2"); }, 0); Promise.resolve().then(() => { console.log("3"); }); console.log("4"); // Output: 1, 4, 3, 2 // Microtasks before macrotasks!
Microtasks (Promise) run before macrotasks (setTimeout)
Sync Code Flow
function greet() { console.log("Hello"); } console.log("Start"); greet(); console.log("End"); // Output: Start, Hello, End // Synchronous = line by line
Synchronous code executes line by line on call stack
Chained Promises
Promise.resolve(1) .then(x => { console.log(x); // 1 return x + 1; }) .then(x => { console.log(x); // 2 return x + 1; }) .then(x => { console.log(x); // 3 }); // Each .then queues a microtask // when the previous resolves
Each .then queues when previous resolves
async/await
async function example() { console.log("1"); await Promise.resolve(); // Everything after await becomes // a microtask console.log("2"); } console.log("A"); example(); console.log("B"); // Output: A, 1, B, 2
await pauses function, queues continuation as microtask
Nested setTimeout
setTimeout(() => { console.log("First macrotask"); setTimeout(() => { console.log("Second macrotask"); }, 0); }, 0); console.log("Sync"); // Output: Sync, First, Second // Each setTimeout queues one // macrotask for next loop cycle
Nested creates new macrotask for next iteration
Microtask in Macrotask
setTimeout(() => { console.log("Macro 1"); Promise.resolve().then(() => { console.log("Micro inside"); }); console.log("Macro 1 end"); }, 0); setTimeout(() => { console.log("Macro 2"); }, 0); // Output: Macro 1, Macro 1 end, // Micro inside, Macro 2
Microtasks created during macrotask run before next macrotask
queueMicrotask
queueMicrotask(() => { console.log("Microtask 1"); queueMicrotask(() => { console.log("Microtask 2"); }); }); console.log("Sync"); // Output: Sync, Microtask 1, Microtask 2 // All microtasks drain before // any macrotask runs
Direct microtask scheduling, can queue more microtasks
Microtask Starvation
// DANGER: This blocks forever! function recursive() { Promise.resolve().then(recursive); } recursive(); setTimeout(() => { console.log("Never runs!"); }, 0); // Microtasks keep adding more // macrotasks are STARVED
Infinite microtasks block all macrotasks forever!
Common Mistakes
- Thinking setTimeout(fn, 0) runs immediately
- Not understanding microtask priority over macrotasks
- Blocking the event loop with long-running synchronous code
Interview Tips
- Draw the event loop diagram (stack, queues, Web APIs)
- Know the order: sync → microtasks → macrotasks
- Explain why Promises are faster than setTimeout