Event Loop in JavaScript
- 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).
JavaScript is single-threaded but handles async operations through the event loop. The call stack executes synchronous code. When async operations finish (timers, fetch, I/O), their callbacks go into a queue. The event loop moves callbacks from the queue to the call stack only when the stack is empty. Microtasks (Promises) run before macrotasks (setTimeout).
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.
/* * Package: io.thecodeforge.js.core */ function multiply(a, b) { return a * b; // [3] pushed then popped } function square(n) { return multiply(n, n); // [2] pushed, calls multiply } function printSquare(n) { const result = square(n); // [1] pushed, calls square console.log(result); } printSquare(4); // Trace: // 1. printSquare(4) is pushed // 2. square(4) is pushed // 3. multiply(4, 4) is pushed // 4. multiply returns 16, popped // 5. square returns 16, popped // 6. printSquare logs 16, popped
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.
/* * Package: io.thecodeforge.js.async */ console.log('1 — start'); // Handed to Web API timer thread setTimeout(() => { console.log('2 — setTimeout callback'); }, 0); console.log('3 — end'); // The Event Loop Check: // 1. Is Stack empty? No (running '3 - end'). // 2. '3 - end' finishes. Stack is empty. // 3. Event Loop moves callback from Macrotask Queue to Stack.
3 — end
2 — setTimeout callback
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).
/* * Package: io.thecodeforge.js.concurrency */ console.log('1 — sync start'); setTimeout(() => console.log('2 — setTimeout'), 0); Promise.resolve() .then(() => console.log('3 — Promise .then')); queueMicrotask(() => console.log('4 — queueMicrotask')); console.log('5 — sync end'); // Execution Logic: // [Sync] 1 and 5 log first. // [Stack Empty] Check Microtasks. // [Micro] 3 and 4 log. // [Micro Empty] Check Macrotasks. // [Macro] 2 logs.
5 — sync end
3 — Promise .then
4 — queueMicrotask
2 — 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.
/* * Package: io.thecodeforge.js.performance */ // BLOCKING VERSION function block() { let i = 0; while (i < 1e9) i++; // Heavy sync work console.log('Done blocking'); } // NON-BLOCKING (Chunked) VERSION function chunkedTask(iterations) { let i = 0; function doWork() { let start = Date.now(); // Work for only 16ms to maintain 60fps while (Date.now() - start < 16 && i < iterations) { i++; } if (i < iterations) { setTimeout(doWork, 0); // Yield control back to loop } else { console.log('Done chunking'); } } doWork(); } chunkedTask(1e9); console.log('UI stays responsive!');
Done chunking
🎯 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).
Interview Questions on This Topic
- QPredict the output order of: console.log, setTimeout(0),
Promise.resolve().then(), and process.nextTick() (in Node.js). - QWhy might a recursive function that uses setTimeout() not cause a 'Maximum call stack size exceeded' error, while a standard recursive function does?
- QExplain how the 'Starvation' of the macrotask queue can happen if a microtask keeps adding more microtasks to its queue.
- 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?
- QLeetCode Style: Implement a basic 'Task Scheduler' that prioritizes tasks based on whether they are marked as 'urgent' (Microtask) or 'standard' (Macrotask).
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.