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..
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- 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 event loop is the core mechanism that enables JavaScript's single-threaded, non-blocking concurrency model. JavaScript runs on a single thread with one call stack — meaning it can only execute one piece of code at a time. Without the event loop, any I/O operation (like fetching data from a network or reading a file) would freeze the entire application until it completes.
The event loop solves this by continuously checking the call stack and, when it's empty, pulling pending tasks from various queues (macrotask and microtask) and pushing them onto the stack for execution. This is what makes asynchronous JavaScript possible despite the language's synchronous, single-threaded nature.
At its core, the event loop works with three key structures: the call stack, the macrotask queue (also called the callback queue or task queue), and the microtask queue. When you call a function, it gets pushed onto the call stack. When you schedule an async operation like setTimeout, fetch, or a DOM event listener, the callback is placed into the appropriate queue after the operation completes.
The event loop's job is to check if the call stack is empty, then process all microtasks (primarily Promise callbacks and queueMicrotask callbacks) before processing a single macrotask. This ordering is critical — Promises always resolve before the next setTimeout callback, even if the timer expires first.
In Node.js, the event loop is implemented by the libuv library and has distinct phases: timers, pending callbacks, idle/prepare, poll, check, and close callbacks. Each phase has its own queue, and the loop iterates through them in order. The poll phase is where most I/O callbacks execute, and it can block waiting for new events.
Understanding these phases is essential for debugging performance issues — for example, setImmediate callbacks run in the check phase, while process.nextTick callbacks run between each phase (not technically part of the event loop, but often grouped with microtasks). Blocking the event loop with CPU-intensive synchronous code (like heavy loops or JSON parsing) halts all async processing, causing dropped frames in browsers or stalled servers in Node.js.
This is why you offload heavy work to Web Workers (browser) or worker threads (Node.js).
Imagine a chef working alone in a kitchen with one cutting board. The chef finishes each task completely before starting the next, but can set a timer or leave a pot on the stove and come back to it. The event loop is the system that tells the chef which waiting task to pick up next, ensuring nothing burns and no order sits forever.
The event loop is what keeps a JavaScript application responsive while handling network requests, user input, and timers on a single thread. Misunderstanding its queue priorities leads to real bugs: UI freezes, out-of-order state updates, and race conditions between Promises and setTimeout. This article breaks down the call stack, macrotask queue, microtask queue, and Node.js phases so you can write predictable async code and avoid blocking the loop in production.
How the Event Loop Actually Manages Asynchronous JavaScript
The event loop is the core mechanism that enables JavaScript's single-threaded model to handle asynchronous operations without blocking. It continuously checks the call stack and task queues, moving callbacks from queues to the stack only when the stack is empty. This is not parallelism — it's cooperative concurrency on one thread.
Key properties: The call stack runs synchronous code first. Macrotasks (setTimeout, I/O) and microtasks (Promise.then, queueMicrotask) are queued separately. After each macrotask, the event loop drains the entire microtask queue before rendering or picking the next macrotask. This means microtasks can starve the loop if they keep adding more microtasks.
In practice, you rely on the event loop whenever you use timers, network requests, or user interactions. Understanding its phases prevents subtle bugs: a setTimeout(fn, 0) does not run immediately — it waits for all pending microtasks and at least one macrotask boundary. This is critical for scheduling UI updates or breaking up CPU-heavy work.
Promise.resolve().then(processNext) for backpressure. The microtask queue grew unbounded, causing the event loop to never process incoming HTTP requests — the service appeared dead.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) {\n return a * b; // [3] pushed then popped\n} 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.
Promise.resolve().then() for microtask priority.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.
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 {\n console.log('Done chunking');\n } } doWork(); } chunkedTask(1e9); console.log('UI stays responsive!');
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.
/* * Package: io.thecodeforge.js.eventloop */ const fs = require('fs'); console.log('1 — start'); setTimeout(() => console.log('2 — setTimeout'), 0); setImmediate(() => console.log('3 — setImmediate')); process.nextTick(() => console.log('4 — nextTick')); Promise.resolve().then(() => console.log('5 — Promise')); console.log('6 — end'); // Node.js order (typical): // 1,6 sync // nextTick (4) runs before Promise? // Actually: process.nextTick runs before microtasks in Node (phase boundary) // But microtasks run after each phase, so after sync, nextTick runs, then microtasks, then timers...
Event Loop Sequence Visual Diagram
The event loop processes tasks in a strict order: first, the current synchronous code on the call stack runs to completion. Then, the microtask queue is fully drained. After that, one macrotask is picked, and the cycle repeats. Between macrotasks, the browser may render the page. This sequence is critical for understanding why certain callbacks run at unexpected times. The diagram below illustrates the flow.
Macrotask vs Microtask (setTimeout vs Promise) Priority Table
The difference in priority between microtasks and macrotasks is not just theoretical — it directly affects the order of execution and can lead to subtle bugs. The table below highlights the key differences with practical examples.
| Aspect | Microtask (Promise.then) | Macrotask (setTimeout) |
|---|---|---|
| Queue priority | Higher — drained completely after every macrotask | Lower — only one per loop iteration |
| Examples | Promise.then, queueMicrotask, MutationObserver | setTimeout, setInterval, I/O, UI events |
| Recursion effect | Can starve macrotasks and UI rendering if recursive | Recursion yields control after each task, allowing other tasks |
| Execution timing | Immediately after current sync stack and before next macrotask | After microtask queue is empty, before next render |
setTimeout(fn,0) vs Promise.resolve().then(fn) | Runs synchronously after current code (microtask) | Runs in next macrotask cycle, at least 4ms later |
Use this table to predict callback order. For instance, Promise.resolve().then(() => console.log('A')); setTimeout(() => console.log('B'), 0); will always print A before B because the microtask runs first.
Promise.resolve().then() for 'as soon as possible' and setTimeout for 'after everything else including rendering'.requestAnimationFrame vs setTimeout/setInterval Guide
When timing code that affects the visual output, the choice between requestAnimationFrame (rAF) and timers can make or break your app's perceived performance. rAF is the browser's signal that it's about to paint a new frame. It runs before the paint and after all macrotasks and microtasks from the current cycle. In contrast, setTimeout and setInterval run at unpredictable times relative to the rendering pipeline.
Key differences:
- rAF fires ~60 times per second, aligned with the monitor's refresh rate. The browser can batch multiple rAF callbacks into a single frame.
- setTimeout(callback, 16) (approx 60fps) may fire when the browser is just about to paint, causing layout thrashing or missed frames if the callback runs too late.
- setInterval compounds errors: if a callback takes longer than the interval, multiple callbacks queue up and run back-to-back, freezing the UI.
When to use each:
- rAF — any code that updates DOM, CSS animations, canvas rendering, or reads layout properties (avoid forced layout).
- setTimeout — deferring non-visual work that doesn't need to sync with the display, like logging or network request batching.
- setInterval — almost never; prefer
setTimeoutwith recursive calls to avoid overlap.
/* * Package: io.thecodeforge.js.timing */ // requestAnimationFrame — syncs with paint let frameCount = 0; function animate() { frameCount++; // Update DOM here — safe from layout thrashing document.title = `Frame ${frameCount}`; requestAnimationFrame(animate); } requestAnimationFrame(animate); // setTimeout — non-visual deferred work function logStats() { console.log('Stats logged at', Date.now()); setTimeout(logStats, 1000); } setTimeout(logStats, 1000); // setInterval — risky if work is variable setInterval(() => { // If this takes 200ms but interval is 100ms, callbacks pile up heavySyncWork(); }, 100);
Blocking the Main Thread — Why Your UI Freezes
A single synchronous task can lock your entire application. No clicks, no animations, no event loop processing. The fix? Never do CPU-heavy work on the main thread. Offload to Web Workers or chunk it with requestIdleCallback. Your job is to keep the stack empty so the event loop can breathe. A frozen UI is the cost of ignoring that rule.
// io.thecodeforge — javascript tutorial // This will freeze the page for ~3 seconds function blockMainThread() { const start = Date.now(); while (Date.now() - start < 3000) { // intentional busy wait } console.log('Blocking function finished'); } document.getElementById('btn').addEventListener('click', () => { console.log('Button clicked — start'); blockMainThread(); console.log('Button clicked — end'); }); console.log('Event loop is free again');
setTimeout(fn, 0) — The Deceptive Delay
setTimeout with 0ms doesn't run after 0 milliseconds. It runs after all synchronous code and all microtasks finish. The minimum delay is clamped to 4ms for nested timeouts. This is why your "instant" timer is always late. Use it only when you need to defer a callback to the macrotask queue — never for precise timing. If you want real delay, use performance.now() timestamps, not setTimeout.
// io.thecodeforge — javascript tutorial console.log('Synchronous start'); setTimeout(() => { console.log('setTimeout 0ms ran'); }, 0); Promise.resolve().then(() => { console.log('Microtask ran'); }); const start = performance.now(); while (performance.now() - start < 10) { // busy wait 10ms to simulate work } console.log('Synchronous end (after 10ms busy wait)');
Callback Hell — A Concrete Example You've Written
Nested callbacks aren't just ugly — they're a memory and maintenance nightmare. Each callback creates a new execution context on the stack, making error handling and early exits a minefield. The real fix is not Promises or async/await syntactic sugar — it's flattening control flow. Promises chain, async/await serialises, but the principle is the same: don't write pyramids of doom. Your production code should read like a top-down story, not a maze.
// io.thecodeforge — javascript tutorial // Production code from a real payment gateway integration function processPayment(orderID, callback) { validateOrder(orderID, (err, order) => { if (err) return callback(err); chargeCard(order.cardToken, (err, charge) => { if (err) return callback(err); updateInventory(order.items, (err, result) => { if (err) return callback(err); sendReceipt(order.email, (err, receipt) => { if (err) return callback(err); callback(null, { charge, receipt }); }); }); }); }); }
Common Event Loop Pitfalls That Burn Production Apps
You can't fix what you can't see. The event loop is subtle — three bugs plague production apps daily. First: promise chains that spawn infinite microtasks. Each .then() queues another microtask. The loop never reaches macrotasks — your UI freezes, your server stops accepting connections. Second: setTimeout inside promises thinking it buys time. It doesn't. It just moves the problem to the next macrotask cycle. Third: mixing process.nextTick with promises in Node.js. Node prioritizes nextTick over microtasks — your carefully ordered logic explodes. Why this happens: the event loop is a strict schedule, not a suggestion box. When you starve one queue, everything downstream starves too. This isn't theory — I've pulled production dumps where requestAnimationFrame stopped firing because a library queued 50k promise resolutions in a single frame.
// io.thecodeforge — javascript tutorial // Starving macrotasks via infinite microtasks function scheduleWork() { let counter = 0; function loop() { if (counter >= 1_000_000) return; counter++; // Each resolution queues a microtask Promise.resolve().then(loop); } loop(); // This never runs until counter hits 1M setTimeout(() => { console.log('I queue in 0ms but run in 10s'); }, 0); } scheduleWork();
setTimeout escape hatch every 1000 iterations.Event Loop Best Practices — Keep It Moving
The event loop is a conveyor belt. Your job is to drop a box, step back, and let it roll. You don't stand on the belt. Rule one: never block the main thread with CPU-bound work. A for loop that chews 200ms kills your responsiveness — offload to Web Workers or setImmediate chunks. Rule two: batch your microtask generation. If you must resolve 10K promises, group them into batches of 100 with a setTimeout between batches. This gives rendering and IO a window to breathe. Rule three: trust the queue order. Promise.resolve().then() runs before setTimeout(fn, 0) — stop fighting it. Structure your code so microtasks handle state updates (fast) and macrotasks handle side effects like logging or network writes (slow). Rule four: measure before you optimize. Use markers to detect stalls above 16ms. If your frame budget blows, the event loop tells you exactly which queue is drowning. Listen to it.performance.now()
// io.thecodeforge — javascript tutorial // Batching microtasks to keep UI responsive function processItems(items, batchSize = 100) { let index = 0; function processBatch() { const end = Math.min(index + batchSize, items.length); for (; index < end; index++) { // Heavy synchronous work per item items[index] *= 2; } if (index < items.length) { // Yield to macrotasks (UI, IO) setTimeout(processBatch, 0); } else { console.log('All items processed'); } } processBatch(); } processItems(Array.from({ length: 10000 }, (_, i) => i));
requestIdleCallback for background tasks — it respects user input priority. If you need polyfill behavior, fall back to setTimeout(fn, 20) to let the event loop breathe.Use-case 1: Splitting CPU-Hungry Tasks
Long-running synchronous tasks (e.g., image processing, data encryption) block the event loop entirely. Instead of running a 5-second loop in one shot, split the work into chunks using setTimeout(fn, 0) to yield control to the event loop between chunks. This allows UI updates, I/O callbacks, and other microtasks to execute. The pattern is a 'time-slicing' technique: process a portion of data, schedule the next chunk, and repeat until complete. This prevents UI freezes in browsers and maintains server responsiveness in Node.js. Always batch work to minimize context-switching overhead (e.g., process 100 items per chunk rather than 1). Without splitting, a single CPU-intensive function can starve the event loop for seconds, causing dropped frames or timeout errors.
// io.thecodeforge — javascript tutorial function processInChunks(arr, chunkSize, callback) { let i = 0; function nextChunk() { const end = Math.min(i + chunkSize, arr.length); for (; i < end; i++) { /* heavy op */ arr[i] * 2; } if (i < arr.length) { setTimeout(nextChunk, 0); // yield } else { callback('done'); } } nextChunk(); } processInChunks(new Array(1e6), 100, console.log);
setTimeout calls. Too large → long blocking bursts. For Node.js, consider Worker Threads for truly parallel CPU work.Use-case 2: Progress Indication
When processing large datasets or file uploads, users expect visual feedback. The event loop pattern allows progress updates without blocking UI rendering. Use requestAnimationFrame (browser) or setImmediate (Node.js) to update a progress bar after each chunk completes. The key is to decouple the work loop from the rendering loop. For example, process 10 items, then schedule a progress update via setTimeout(fn, 0) which runs after the current macrotask but before the next paint. This avoids layout thrashing and ensures the user sees incremental progress. In Node.js, emit events or update a shared state object for logging. Never update progress inside a tight synchronous loop — the UI thread never gets a chance to repaint until the loop finishes.
// io.thecodeforge — javascript tutorial const total = 5000, chunk = 50; let processed = 0; function doWork() { for (let i = 0; i < chunk && processed < total; i++) { processed++; // simulate heavy work } updateProgressBar((processed / total) * 100); if (processed < total) { // schedule next chunk after paint requestAnimationFrame(doWork); } else { console.log('100% complete'); } } function updateProgressBar(pct) { /* DOM update */ } doWork();
requestAnimationFrame is ideal for progress bars because it runs just before the browser paints. Avoid setTimeout for UI progress — it may cause jank by triggering updates between frames.Use-case 3: Doing Something After the Event
Sometimes you need to execute logic after all current pending events are processed — not just after a timeout. Use queueMicrotask for high-priority follow-ups (e.g., cache write after data fetch) or setTimeout(fn, 0) for deferring less critical tasks (e.g., logging after user action). The rule: microtasks run after every macrotask, before rendering. So if you need cleanup immediately after a user click but before the browser paints, use a microtask. If you want to give the browser a chance to handle other events first (e.g., scroll, resize), use setTimeout(fn, 0). This pattern prevents 'callback hell' by chaining logically but yielding control at natural breakpoints. Always prefer explicit microtasks over nested timeouts for dependency ordering.
// io.thecodeforge — javascript tutorial button.addEventListener('click', () => { // Immediately update UI showSpinner(true); // High-priority post-click work queueMicrotask(() => { console.log('Microtask: analytics sent before paint'); }); // Deferred: let other events process first setTimeout(() => { console.log('Timeout(0): deferred logging after scroll'); }, 0); });
queueMicrotask for CPU-heavy work — it blocks rendering. Use it only for quick state updates or error recovery. Heavy lifting belongs in macrotasks or Web Workers.Summary
The event loop is not just theory — it's a practical tool for crafting responsive apps. Split heavy tasks into time-sliced chunks using setTimeout(fn, 0) to avoid blocking. For progress indication, pair chunk processing with requestAnimationFrame to update the UI smoothly before each paint. When you need to run code after an event, choose between queueMicrotask (high-priority, immediate) and setTimeout(fn, 0) (deferred, yielding to other handlers). These three patterns — task splitting, progress updates, and post-event orchestration — form the bedrock of non-blocking JavaScript. Master them, and your apps will never freeze, your users will see live progress, and your code will gracefully handle the asynchronous nature of the event loop. Remember: the event loop is a cooperative multitasking system — yield often, yield wisely.
The UI Freeze That Took Down a Dashboard
- await does not make synchronous code asynchronous — it only waits for Promises.
- Any long synchronous operation in the main thread blocks the Event Loop.
- For CPU-heavy work, always use Web Workers or break the work into chunks using setTimeout.
node --prof to generate a flame graph. Focus on functions that consume significant CPU in the main thread.In Chrome DevTools: Performance > Record > Load > StopLook for long Sync sections in flame chartIn Node.js: node --trace-events-enabled --trace-event-categories=v8,node,node.async_hooksOr in browser: performance.measureUserAgentSpecificMemory()npx clinic doctor -- node server.jsnpx 0x server.js| 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
Common mistakes to avoid
3 patternsAssuming setTimeout(fn, 0) runs immediately after current code
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
Creating microtask recursion without a termination condition
Interview Questions on This Topic
Predict the output order of: console.log, setTimeout(0), Promise.resolve().then(), and process.nextTick() (in Node.js).
Why might a recursive function that uses setTimeout() not cause a 'Maximum call stack size exceeded' error, while a standard recursive function does?
Explain how the 'Starvation' of the macrotask queue can happen if a microtask keeps adding more microtasks to its queue.
In the context of the Event Loop, why is it usually better to perform heavy calculations in a Web Worker rather than the main thread?
LeetCode Style: Implement a basic 'Task Scheduler' that prioritizes tasks based on whether they are marked as 'urgent' (Microtask) or 'standard' (Macrotask).
Promise.resolve().then(() => task). For standard, use setTimeout(task, 0). Ensure that urgent tasks always run before standard tasks by leveraging the event loop's natural priority.Frequently Asked Questions
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.
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.
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.
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.
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.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's Advanced JS. Mark it forged?
9 min read · try the examples if you haven't