Event Loop in JavaScript
How the JavaScript event loop works — call stack, Web APIs, callback queue, microtask queue, and why setTimeout(fn, 0) does not run immediately.
- JavaScript is single-threaded but handles async operations through the event loop
- Call stack executes synchronous code; async callbacks wait in queues
- Event loop moves callbacks to stack only when stack is empty
- Microtasks (Promises) run before macrotasks (setTimeout)
- Performance trap: a heavy sync task blocks all async work until done
- Production insight: UI freezes if event loop is blocked for >50ms
The Call Stack
The call stack is a LIFO (last in, first out) data structure that tracks which function is currently executing. When a function is called, it is pushed on. When it returns, it is popped off. If the stack is busy, nothing else can happen—this is why we say JavaScript is 'blocking' by nature.
Async Operations and the Queue
When you call setTimeout or fetch, JavaScript engine doesn't wait. It hands the work off to the environment's Web APIs (in browsers) or C++ APIs (in Node.js). Your code continues running immediately. When the timer expires or the data returns, the callback is placed in the Macrotask Queue (also known as the Task Queue). It sits there patiently until the Call Stack is completely clear.
Microtask Queue — Promises Run First
Not all queues are created equal. JavaScript prioritizes the Microtask Queue (used by Promises and MutationObserver). After the current synchronous task finishes, the Event Loop will drain the entire Microtask Queue before it even looks at the Macrotask Queue. If a microtask schedules another microtask, that new one also runs before the next macrotask (like a setTimeout).
Why This Matters — Blocking the Event Loop
Because the Event Loop can only move a task to the stack when the stack is empty, a heavy calculation (like finding a large prime number) will 'block' the loop. During this time, the browser cannot render updates, and the UI becomes unresponsive. This is why we offload heavy CPU tasks to Web Workers or break them into asynchronous chunks.
Node.js Event Loop Phases (libuv)
Node.js extends the event loop with additional phases via libuv. The order is: timers, pending I/O callbacks, idle/prepare, poll, check (setImmediate), close callbacks. process.nextTick() runs between each phase, before the microtask queue. This explains why setImmediate vs setTimeout ordering can be non-deterministic depending on I/O.
| Aspect | Microtask Queue | Macrotask Queue |
|---|---|---|
| Priority | Higher — drained after every macrotask | Lower — only one per loop iteration |
| Examples | Promise.then, queueMicrotask, MutationObserver | setTimeout, setInterval, I/O, UI events |
| Scheduling recursion | Can starve macrotasks if recursive | Recursion yields control after each task |
| Execution context | Between macrotask and next render | After microtasks, before render |
Key Takeaways
- JavaScript is single-threaded — only one function executes at a time in the Call Stack.
- The Event Loop is a continuous process that monitors the stack and the queues to decide what runs next.
- The 'Handoff': Synchronous Code > Microtasks (Promises) > Macrotasks (setTimeout/IO).
- setTimeout(fn, 0) does not mean 'run immediately' — it means 'queue this for the next available tick after the stack is clear'.
- Blocking the main thread is a cardinal sin in JS; it freezes the entire environment (UI/Node.js server).
- process.nextTick() in Node.js runs before microtasks and can starve I/O if used recursively — prefer setImmediate.
Common Mistakes to Avoid
- Assuming setTimeout(fn, 0) runs immediately after current code
Symptom: Code after setTimeout executes before the callback, leading to race conditions where DOM updates expected from the callback aren't ready.
Fix: Use microtasks (Promise.resolve().then()) for immediate after current sync, or understand that setTimeout always waits for a full event loop cycle. - Blocking the Event Loop with synchronous loops in async functions
Symptom: UI freezes during async operations. The async function is awaited, but inside it a while loop blocks for seconds.
Fix: Move long synchronous work to a Web Worker (browser) or worker_threads (Node.js). Alternatively, chunk the work with requestAnimationFrame or setTimeout. - Creating microtask recursion without a termination condition
Symptom: UI hangs, setTimeout callbacks never fire, browser tab becomes unresponsive. The microtask queue grows infinitely.
Fix: Add a depth counter; after N iterations, switch to setTimeout to allow other macrotasks to process.
Interview Questions on This Topic
- QPredict the output order of: console.log, setTimeout(0),
Promise.resolve().then(), and process.nextTick() (in Node.js).SeniorReveal - QWhy might a recursive function that uses setTimeout() not cause a 'Maximum call stack size exceeded' error, while a standard recursive function does?Mid-levelReveal
- QExplain how the 'Starvation' of the macrotask queue can happen if a microtask keeps adding more microtasks to its queue.Mid-levelReveal
- QIn the context of the Event Loop, why is it usually better to perform heavy calculations in a Web Worker rather than the main thread?JuniorReveal
- QLeetCode Style: Implement a basic 'Task Scheduler' that prioritizes tasks based on whether they are marked as 'urgent' (Microtask) or 'standard' (Macrotask).SeniorReveal
Frequently Asked Questions
What is the difference between the microtask queue and the macrotask queue?
Microtasks include Promise callbacks (.then, .catch, .finally) and queueMicrotask. Macrotasks include setTimeout, setInterval, setImmediate (Node.js), and I/O callbacks. The Event Loop will always drain the entire microtask queue completely after every macrotask before moving to the next one in line.
If setTimeout(fn, 0) does not run immediately, when exactly does it run?
It runs after all currently executing synchronous code has finished and the microtask queue has been fully emptied. The '0ms' delay is essentially a request to run the function as soon as possible on the next 'tick' of the event loop.
How does async/await relate to the event loop?
async/await is built on top of Promises. When the engine hits an await keyword, the execution of that specific function is paused, and it yields control back to the main thread. The code following the await is treated like a callback in the microtask queue, which executes once the awaited promise resolves.
Does Node.js have a different event loop than the browser?
While the core concept is identical, Node.js uses the libuv library which has additional phases (Poll, Check, Close callbacks) and unique features like process.nextTick(), which has even higher priority than standard microtasks.
What is the 'Heavy page' warning in Chrome and how to avoid it?
Chrome shows a 'Heavy page' warning when the main thread is blocked for more than 1 second, indicating the Event Loop is frozen. To avoid it, keep synchronous work under 50ms, use Web Workers for heavy computation, or chunk work using requestIdleCallback or setTimeout.
That's Advanced JS. Mark it forged?
3 min read · try the examples if you haven't