JavaScript — Event Loop
JavaScript is single-threaded — it has one call stack and executes one piece of code at a time. Yet it handles asynchronous operations like network requests, timers, and user events without blocking. This illusion of concurrency is powered by the Event Loop, the hidden orchestration mechanism that manages how and when code is executed.
The event loop continuously checks the call stack and the various task queues. When the call stack is empty, it takes the first task from the microtask queue, then from the task (macrotask) queue, and pushes it onto the call stack for execution. This cycle runs as long as the program lives.
Understanding the event loop is crucial for writing performant, bug-free asynchronous JavaScript. It explains why setTimeout(fn, 0) does not run immediately, why Promise callbacks execute before timer callbacks, and how a long-running loop can freeze the UI.
The call stack is a LIFO (Last In, First Out) data structure that tracks function execution. When a function is called, a frame is pushed onto the stack. When the function returns, its frame is popped off. The stack grows and shrinks as functions call other functions and return.
| 1 | // Call stack visualization |
| 2 | function multiply(a, b) { |
| 3 | return a * b; |
| 4 | } |
| 5 | |
| 6 | function square(n) { |
| 7 | return multiply(n, n); // multiply pushed onto stack |
| 8 | } |
| 9 | |
| 10 | function printSquare(n) { |
| 11 | const result = square(n); // square pushed, then multiply inside it |
| 12 | console.log(result); |
| 13 | } |
| 14 | |
| 15 | printSquare(5); // printSquare pushed first |
| 16 | |
| 17 | // Stack trace at each step: |
| 18 | // 1. printSquare(5) — [printSquare] |
| 19 | // 2. square(5) — [printSquare, square] |
| 20 | // 3. multiply(5, 5) — [printSquare, square, multiply] |
| 21 | // 4. multiply returns 25 — [printSquare, square] |
| 22 | // 5. square returns 25 — [printSquare] |
| 23 | // 6. console.log(25) — [printSquare, console.log] |
| 24 | // 7. console.log returns — [printSquare] |
| 25 | // 8. printSquare returns — [] |
| 26 | |
| 27 | // Stack overflow |
| 28 | function infinite() { |
| 29 | return infinite(); // RangeError: Maximum call stack size exceeded |
| 30 | } |
| 31 | // infinite(); // Uncomment at your own risk! |
| 32 | |
| 33 | // Practical: stack trace in errors |
| 34 | function fail() { |
| 35 | throw new Error("Something broke"); |
| 36 | } |
| 37 | function wrapper() { |
| 38 | fail(); |
| 39 | } |
| 40 | try { |
| 41 | wrapper(); |
| 42 | } catch (e) { |
| 43 | console.log(e.stack); |
| 44 | // Shows: fail → wrapper → (main) |
| 45 | } |
| 46 | |
| 47 | // Async does not mean parallel |
| 48 | // setTimeout just schedules a task — it doesn't create a new thread |
pro tip
The task queue (also called the macrotask queue or callback queue) holds tasks that are waiting to be executed. Tasks include setTimeout, setInterval, setImmediate (Node.js), I/O callbacks, and UI rendering events. The event loop picks one task per iteration from this queue.
| 1 | // setTimeout adds a task to the macrotask queue |
| 2 | console.log("Start"); |
| 3 | |
| 4 | setTimeout(() => { |
| 5 | console.log("Timeout 1"); |
| 6 | }, 0); |
| 7 | |
| 8 | setTimeout(() => { |
| 9 | console.log("Timeout 2"); |
| 10 | }, 0); |
| 11 | |
| 12 | console.log("End"); |
| 13 | |
| 14 | // Output: |
| 15 | // Start |
| 16 | // End |
| 17 | // Timeout 1 |
| 18 | // Timeout 2 |
| 19 | |
| 20 | // Why? The console.log calls are direct, synchronous calls. |
| 21 | // setTimeout(fn, 0) schedules the callback, but it must wait |
| 22 | // until the current stack is empty AND no pending microtasks. |
| 23 | |
| 24 | // Multiple timers with delays |
| 25 | console.log("A"); |
| 26 | setTimeout(() => console.log("B"), 100); |
| 27 | setTimeout(() => console.log("C"), 0); |
| 28 | setTimeout(() => console.log("D"), 50); |
| 29 | console.log("E"); |
| 30 | // Output: A, E, C, D, B |
| 31 | // Explanation: |
| 32 | // A: synchronous |
| 33 | // E: synchronous |
| 34 | // C: 0ms timer — available immediately, but queued |
| 35 | // D: 50ms — queued after C |
| 36 | // B: 100ms — queued last |
| 37 | |
| 38 | // I/O tasks also go to the task queue |
| 39 | const fs = require("fs"); // Node.js example |
| 40 | fs.readFile("file.txt", () => { |
| 41 | console.log("File read complete"); // macrotask |
| 42 | }); |
| 43 | console.log("Reading file..."); |
| 44 | // Output: "Reading file..." then "File read complete" |
| Source | Queue | Examples |
|---|---|---|
| Timers | Macrotask | setTimeout, setInterval |
| I/O | Macrotask | File reads, network requests, database queries |
| UI Events | Macrotask | click, scroll, keydown, resize |
| setImmediate | Macrotask | Node.js only (similar to 0ms timer) |
| Rendering | Macrotask | requestAnimationFrame (special queue) |
The microtask queue has higher priority than the task queue. After each task completes, the event loop processes all pending microtasks before moving to the next task. This means microtasks can starve the task queue if they keep adding more microtasks. Promise callbacks, queueMicrotask, and MutationObserver callbacks are all microtasks.
| 1 | // Promise callbacks are microtasks |
| 2 | console.log("Start"); |
| 3 | |
| 4 | Promise.resolve().then(() => { |
| 5 | console.log("Promise 1"); |
| 6 | }); |
| 7 | |
| 8 | Promise.resolve().then(() => { |
| 9 | console.log("Promise 2"); |
| 10 | }); |
| 11 | |
| 12 | setTimeout(() => { |
| 13 | console.log("Timeout"); |
| 14 | }, 0); |
| 15 | |
| 16 | console.log("End"); |
| 17 | |
| 18 | // Output: |
| 19 | // Start |
| 20 | // End |
| 21 | // Promise 1 |
| 22 | // Promise 2 |
| 23 | // Timeout |
| 24 | |
| 25 | // Explanation: |
| 26 | // 1. Synchronous: "Start" and "End" run immediately |
| 27 | // 2. Promise callbacks go to microtask queue |
| 28 | // 3. setTimeout goes to macrotask queue |
| 29 | // 4. After stack clears, ALL microtasks run before next macrotask |
| 30 | // 5. Microtasks: Promise 1, Promise 2 |
| 31 | // 6. Macrotask: Timeout |
| 32 | |
| 33 | // queueMicrotask API |
| 34 | console.log(1); |
| 35 | queueMicrotask(() => console.log(2)); |
| 36 | console.log(3); |
| 37 | // Output: 1, 3, 2 |
| 38 | |
| 39 | // Microtask queue is FIFO |
| 40 | queueMicrotask(() => console.log("A")); |
| 41 | queueMicrotask(() => console.log("B")); |
| 42 | // After current stack clears: A, B |
| 43 | |
| 44 | // MutationObserver callbacks are microtasks |
| 45 | const target = document.createElement("div"); |
| 46 | const observer = new MutationObserver(() => console.log("DOM mutated")); |
| 47 | observer.observe(target, { attributes: true }); |
| 48 | target.setAttribute("data-x", "1"); // triggers microtask |
warning
The event loop operates in a cycle with multiple phases. In the browser, the general loop is: execute current stack → process all microtasks → render (if needed) → pick next macrotask. Node.js has a more complex phase system: timers → pending callbacks → idle/prepare → poll → check (setImmediate) → close callbacks.
| 1 | // Event loop phase demonstration |
| 2 | console.log("1: Sync start"); |
| 3 | |
| 4 | setTimeout(() => { |
| 5 | console.log("2: setTimeout (macrotask)"); |
| 6 | }, 0); |
| 7 | |
| 8 | Promise.resolve() |
| 9 | .then(() => console.log("3: Promise microtask 1")) |
| 10 | .then(() => console.log("4: Promise microtask 2")); |
| 11 | |
| 12 | queueMicrotask(() => console.log("5: queueMicrotask")); |
| 13 | |
| 14 | console.log("6: Sync end"); |
| 15 | |
| 16 | // Output: |
| 17 | // 1: Sync start |
| 18 | // 6: Sync end |
| 19 | // 3: Promise microtask 1 |
| 20 | // 4: Promise microtask 2 |
| 21 | // 5: queueMicrotask |
| 22 | // 2: setTimeout (macrotask) |
| 23 | |
| 24 | // Order within microtasks: |
| 25 | // Promise.then callbacks execute before queueMicrotask |
| 26 | // (both are microtasks, order depends on when they were queued) |
| 27 | |
| 28 | // Nested microtask scenario |
| 29 | Promise.resolve().then(() => { |
| 30 | console.log("A"); |
| 31 | Promise.resolve().then(() => { |
| 32 | console.log("B"); |
| 33 | Promise.resolve().then(() => { |
| 34 | console.log("C"); |
| 35 | }); |
| 36 | }); |
| 37 | }); |
| 38 | setTimeout(() => console.log("D"), 0); |
| 39 | // Output: A, B, C, D |
| 40 | // All nested microtasks drain before macrotask D |
| 41 | |
| 42 | // Node.js event loop phases |
| 43 | // timers → pending callbacks → idle → poll → check → close |
| 44 | // setImmediate fires in the "check" phase |
| 45 | // setTimeout fires in the "timers" phase |
| 46 | // I/O callbacks fire in the "poll" phase |
setTimeout(fn, 0) does not run the callback immediately — it schedules it to run after the current stack is empty and all pending microtasks have been processed. The minimum delay in modern browsers is actually 0ms (or 4ms for nested timeouts), but the callback still goes to the back of the macrotask queue.
| 1 | // setTimeout(fn, 0) — defers execution |
| 2 | console.log("A"); |
| 3 | setTimeout(() => console.log("B"), 0); |
| 4 | console.log("C"); |
| 5 | // A, C, B |
| 6 | |
| 7 | // Use case 1: breaking up long synchronous work |
| 8 | function processLargeArray(items) { |
| 9 | // Instead of processing all at once (blocks UI): |
| 10 | let i = 0; |
| 11 | function chunk() { |
| 12 | const start = performance.now(); |
| 13 | while (i < items.length && performance.now() - start < 16) { |
| 14 | process(items[i]); // do ~16ms of work |
| 15 | i++; |
| 16 | } |
| 17 | if (i < items.length) { |
| 18 | setTimeout(chunk, 0); // schedule next chunk |
| 19 | } |
| 20 | } |
| 21 | chunk(); |
| 22 | } |
| 23 | |
| 24 | // Use case 2: deferring to after current event handlers |
| 25 | element.addEventListener("click", (e) => { |
| 26 | // Do something synchronously |
| 27 | element.classList.add("clicked"); |
| 28 | |
| 29 | // Defer heavy work |
| 30 | setTimeout(() => { |
| 31 | performExpensiveOperation(); |
| 32 | }, 0); |
| 33 | }); |
| 34 | |
| 35 | // Use case 3: yielding to the browser for rendering |
| 36 | function animate() { |
| 37 | // Update position |
| 38 | element.style.left = x + "px"; |
| 39 | |
| 40 | // Allow browser to render before next animation step |
| 41 | setTimeout(() => { |
| 42 | x += 1; |
| 43 | if (x < 100) animate(); |
| 44 | }, 0); |
| 45 | // Better: use requestAnimationFrame instead |
| 46 | } |
| 47 | |
| 48 | // Minimum delay in HTML spec: |
| 49 | // Browsers: >= 0ms (4ms for nested timeouts > 5 levels deep) |
| 50 | // setTimeout(() => {}, 0) — minimum delay after queueing |
info
requestAnimationFrame (rAF) tells the browser to run a callback before the next paint cycle. It is the standard way to create smooth animations — the browser synchronizes the callback with its rendering schedule, typically 60 times per second (every ~16.67ms). rAF callbacks execute after microtasks but before the rendering phase.
| 1 | // Basic animation loop |
| 2 | let position = 0; |
| 3 | const element = document.getElementById("animated"); |
| 4 | |
| 5 | function animate(timestamp) { |
| 6 | position += 2; |
| 7 | element.style.transform = `translateX(${position}px)`; |
| 8 | |
| 9 | if (position < 500) { |
| 10 | requestAnimationFrame(animate); // schedule next frame |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | requestAnimationFrame(animate); |
| 15 | |
| 16 | // Timestamp argument |
| 17 | let startTime = null; |
| 18 | function step(timestamp) { |
| 19 | if (!startTime) startTime = timestamp; |
| 20 | const elapsed = timestamp - startTime; |
| 21 | |
| 22 | element.style.opacity = Math.min(elapsed / 1000, 1); |
| 23 | // 0 → 1 opacity over 1 second |
| 24 | |
| 25 | if (elapsed < 1000) { |
| 26 | requestAnimationFrame(step); |
| 27 | } |
| 28 | } |
| 29 | requestAnimationFrame(step); |
| 30 | |
| 31 | // rAF vs setTimeout for animation: |
| 32 | // setTimeout: fires whenever queue allows (may be late) |
| 33 | // rAF: fires right before paint, matches display refresh rate |
| 34 | |
| 35 | // rAF pauses when tab is inactive (saves battery/CPU) |
| 36 | document.addEventListener("visibilitychange", () => { |
| 37 | if (document.hidden) { |
| 38 | cancelAnimationFrame(rafId); // stop when hidden |
| 39 | } |
| 40 | }); |
| 41 | |
| 42 | // Multiple rAF callbacks |
| 43 | // All registered rAF callbacks for the same frame run in order |
| 44 | requestAnimationFrame(() => console.log("A")); |
| 45 | requestAnimationFrame(() => console.log("B")); |
| 46 | // Both run before the next paint |
| 47 | |
| 48 | // rAF timing in the event loop: |
| 49 | // 1. Execute macrotask |
| 50 | // 2. Drain microtasks |
| 51 | // 3. Run rAF callbacks (even if multiple) |
| 52 | // 4. Style calculation & layout |
| 53 | // 5. Paint |
| Method | Timing | Best For |
|---|---|---|
| requestAnimationFrame | Before paint (~60fps) | Animations, visual updates, DOM measurements |
| setTimeout(fn, 0) | Next macrotask iteration | Deferring work, breaking up long tasks |
| requestIdleCallback | During idle periods | Non-critical background work |
| queueMicrotask | After current task, before rAF/paint | Promise-like async, state consistency |
requestIdleCallback (rIC) schedules a callback to run during idle periods — when the browser has processed all pending tasks and has free time before the next frame. It receives a IdleDeadline object with timeRemaining() and didTimeout. This is ideal for non-critical, background work.
| 1 | // Basic idle callback |
| 2 | requestIdleCallback((deadline) => { |
| 3 | console.log("Idle time remaining:", deadline.timeRemaining()); |
| 4 | // timeRemaining() returns how many ms until the browser needs to paint |
| 5 | }); |
| 6 | |
| 7 | // Chunked work during idle time |
| 8 | function processIdleChunks(items, callback) { |
| 9 | let index = 0; |
| 10 | |
| 11 | function doWork(deadline) { |
| 12 | // Work while there's idle time and items remain |
| 13 | while (deadline.timeRemaining() > 0 && index < items.length) { |
| 14 | process(items[index]); // do one unit of work |
| 15 | index++; |
| 16 | } |
| 17 | |
| 18 | if (index < items.length) { |
| 19 | // More work to do — request next idle period |
| 20 | requestIdleCallback(doWork); |
| 21 | } else { |
| 22 | callback(); // all done |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | requestIdleCallback(doWork); |
| 27 | } |
| 28 | |
| 29 | // Options — timeout ensures it eventually runs |
| 30 | requestIdleCallback(importantWork, { timeout: 2000 }); |
| 31 | // If no idle time within 2000ms, it runs regardless |
| 32 | // deadline.didTimeout will be true |
| 33 | |
| 34 | // Use cases: |
| 35 | // 1. Analytics tracking |
| 36 | // 2. Prefetching/Preloading |
| 37 | // 3. Logging/debug data |
| 38 | // 4. Non-critical state reconciliation |
| 39 | // 5. Lazy rendering off-screen content |
| 40 | |
| 41 | // Note: rIC is not available in all environments |
| 42 | // Node.js doesn't have it, Safari requires polyfill |
| 43 | if (typeof requestIdleCallback === "undefined") { |
| 44 | // Fallback to setTimeout |
| 45 | globalThis.requestIdleCallback = (fn) => setTimeout(() => fn({ |
| 46 | timeRemaining: () => 50, |
| 47 | didTimeout: false |
| 48 | }), 1); |
| 49 | } |
| 50 | |
| 51 | // rIC vs rAF: |
| 52 | // rAF: "I need to update the screen NOW" |
| 53 | // rIC: "I have background work, run it when you're free" |
pro tip
Because JavaScript is single-threaded, any long-running synchronous operation blocks the event loop. While the call stack is busy, no other code runs — no timers fire, no promise callbacks execute, no UI events are processed, and the browser cannot repaint. This results in a frozen, unresponsive UI.
| 1 | // Blocking the event loop |
| 2 | console.log("Start"); |
| 3 | |
| 4 | // Block for 3 seconds |
| 5 | const start = Date.now(); |
| 6 | while (Date.now() - start < 3000) { |
| 7 | // busy wait — blocks everything! |
| 8 | } |
| 9 | |
| 10 | // This won't execute until 3 seconds later |
| 11 | console.log("End after 3 seconds"); |
| 12 | |
| 13 | // During the while loop: |
| 14 | // - No setTimeout callbacks run |
| 15 | // - No promise resolutions process |
| 16 | // - UI is frozen (user can't click, scroll, or type) |
| 17 | // - requestAnimationFrame callbacks are delayed |
| 18 | |
| 19 | // Real-world blocking patterns: |
| 20 | |
| 21 | // 1. Heavy synchronous computation |
| 22 | function computePrimes(limit) { |
| 23 | const primes = []; |
| 24 | for (let i = 2; i < limit; i++) { |
| 25 | let isPrime = true; |
| 26 | for (let j = 2; j <= Math.sqrt(i); j++) { |
| 27 | if (i % j === 0) { isPrime = false; break; } |
| 28 | } |
| 29 | if (isPrime) primes.push(i); |
| 30 | } |
| 31 | return primes; |
| 32 | } |
| 33 | // computePrimes(10000000); // blocks for seconds! |
| 34 | |
| 35 | // 2. Large JSON parsing |
| 36 | // const data = JSON.parse(hugeJsonString); // blocks while parsing |
| 37 | |
| 38 | // 3. Regular expression backtracking |
| 39 | // /(a+)+b/.exec("aaaaaaaaaaaaaaaaaaaaaaaaac"); // catastrophic backtracking |
| 40 | |
| 41 | // 4. Infinite loop / recursion |
| 42 | // while (true) {} // forever block |
| 43 | |
| 44 | // 5. Synchronous network requests (Node.js) |
| 45 | // const data = fs.readFileSync("large-file.txt"); // blocks |
| 46 | |
| 47 | // Fix: split work into chunks, use Workers, or async APIs |
| 48 | function nonBlockingPrimes(limit, chunkSize = 10000) { |
| 49 | // ... chunked approach with setTimeout yields |
| 50 | } |
| 51 | |
| 52 | // Web Workers for heavy computation |
| 53 | const worker = new Worker("prime-worker.js"); |
| 54 | worker.postMessage({ limit: 10000000 }); |
| 55 | worker.onmessage = (e) => { |
| 56 | console.log("Primes computed:", e.data); |
| 57 | }; |
| 58 | console.log("Main thread is free during computation!"); |
danger
Because the event loop processes all pending microtasks before moving to the next macrotask, a microtask that keeps adding more microtasks to the queue can starve the macrotask queue indefinitely. This is called microtask starvation — timers never fire, rendering never happens, and the UI freezes.
| 1 | // Microtask starvation example |
| 2 | function starve() { |
| 3 | // Each microtask adds another microtask — infinite loop! |
| 4 | Promise.resolve().then(() => { |
| 5 | starve(); |
| 6 | }); |
| 7 | } |
| 8 | starve(); |
| 9 | setTimeout(() => console.log("This never runs"), 0); |
| 10 | // The macrotask queue is never reached! |
| 11 | |
| 12 | // Similar with queueMicrotask |
| 13 | function starveQueue() { |
| 14 | queueMicrotask(() => { |
| 15 | starveQueue(); // keeps adding more microtasks |
| 16 | }); |
| 17 | } |
| 18 | starveQueue(); |
| 19 | // Timers, I/O, and rendering are permanently blocked |
| 20 | |
| 21 | // Recursive promise chain |
| 22 | function recursivePromise() { |
| 23 | return Promise.resolve().then(recursivePromise); |
| 24 | } |
| 25 | recursivePromise(); |
| 26 | // Microtask queue never empties — starvation! |
| 27 | |
| 28 | // Fix: periodically yield to macrotask queue |
| 29 | function safeRecursion(depth = 0) { |
| 30 | if (depth % 100 === 0) { |
| 31 | // Every 100 iterations, defer to macrotask |
| 32 | setTimeout(() => safeRecursion(depth + 1), 0); |
| 33 | } else { |
| 34 | Promise.resolve().then(() => safeRecursion(depth + 1)); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // Alternative: use a counter and setTimeout |
| 39 | function processWithYield(items) { |
| 40 | let i = 0; |
| 41 | function next() { |
| 42 | if (i >= items.length) return; |
| 43 | // Process a batch |
| 44 | for (let j = 0; j < 50 && i < items.length; j++, i++) { |
| 45 | process(items[i]); |
| 46 | } |
| 47 | // Yield to event loop |
| 48 | setTimeout(next, 0); |
| 49 | } |
| 50 | next(); |
| 51 | } |
| 52 | |
| 53 | // Detecting starvation: |
| 54 | // - Chrome DevTools Performance tab shows "long tasks" |
| 55 | // - Timers fire much later than expected |
| 56 | // - requestAnimationFrame callbacks are clustered |
warning
Debugging event loop issues requires understanding async execution order. Here are practical techniques for inspecting and debugging the event loop behavior in your code.
| 1 | // 1. Trace execution order with markers |
| 2 | console.log("1: Sync"); |
| 3 | Promise.resolve().then(() => console.log("2: Microtask")); |
| 4 | setTimeout(() => console.log("3: Macrotask"), 0); |
| 5 | console.log("4: Sync"); |
| 6 | // Helps verify your understanding of the queue order |
| 7 | |
| 8 | // 2. Use performance.now() for timing |
| 9 | const start = performance.now(); |
| 10 | setTimeout(() => { |
| 11 | const elapsed = performance.now() - start; |
| 12 | console.log(`Actually delayed by ${elapsed}ms (requested 0ms)`); |
| 13 | // Shows the real delay, revealing queue depth |
| 14 | }, 0); |
| 15 | |
| 16 | // 3. Chrome DevTools Performance panel |
| 17 | // Record and look for: |
| 18 | // - "Task" blocks (yellow) — long synchronous work |
| 19 | // - "Timer Fired" — actual vs expected timer delay |
| 20 | // - "Animation Frame Fired" — rAF timing |
| 21 | // - Microtask checkpoints (fine lines between tasks) |
| 22 | |
| 23 | // 4. Add trace logging to async chains |
| 24 | function tracedPromise(name, fn) { |
| 25 | console.log(`${name}: started`); |
| 26 | return fn().then((result) => { |
| 27 | console.log(`${name}: resolved`); |
| 28 | return result; |
| 29 | }); |
| 30 | } |
| 31 | tracedPromise("fetch", () => fetch("/data")) |
| 32 | .then(data => console.log("Data:", data)); |
| 33 | |
| 34 | // 5. Detect long tasks |
| 35 | const observer = new PerformanceObserver((list) => { |
| 36 | for (const entry of list.getEntries()) { |
| 37 | console.warn(`Long task: ${entry.duration}ms — ${entry.name}`); |
| 38 | } |
| 39 | }); |
| 40 | observer.observe({ entryTypes: ["longtask"] }); |
| 41 | |
| 42 | // 6. Event loop lag detection |
| 43 | let lastCheck = performance.now(); |
| 44 | setInterval(() => { |
| 45 | const now = performance.now(); |
| 46 | const lag = now - lastCheck - 1000; // expected 1000ms interval |
| 47 | if (lag > 100) { |
| 48 | console.warn(`Event loop lag: ${lag}ms behind schedule`); |
| 49 | } |
| 50 | lastCheck = now; |
| 51 | }, 1000); |
Event Loop Debugging Quick Reference:
pro tip