|$ curl https://forge-ai.dev/api/markdown?path=docs/js/event-loop
$cat docs/javascript-—-event-loop.md
updated Recently·35 min read·published

JavaScript — Event Loop

JavaScriptEvent LoopAsyncAdvancedAdvanced
Introduction

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

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.

call-stack.js
JavaScript
1// Call stack visualization
2function multiply(a, b) {
3 return a * b;
4}
5
6function square(n) {
7 return multiply(n, n); // multiply pushed onto stack
8}
9
10function printSquare(n) {
11 const result = square(n); // square pushed, then multiply inside it
12 console.log(result);
13}
14
15printSquare(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
28function 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
34function fail() {
35 throw new Error("Something broke");
36}
37function wrapper() {
38 fail();
39}
40try {
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 call stack has a limited size (typically ~10,000–50,000 frames depending on the engine). A stack overflow occurs when functions recurse too deeply. For deep recursion, consider using a trampoline pattern or converting to an iterative approach with an explicit stack.
Task Queue (Macrotasks)

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.

task-queue.js
JavaScript
1// setTimeout adds a task to the macrotask queue
2console.log("Start");
3
4setTimeout(() => {
5 console.log("Timeout 1");
6}, 0);
7
8setTimeout(() => {
9 console.log("Timeout 2");
10}, 0);
11
12console.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
25console.log("A");
26setTimeout(() => console.log("B"), 100);
27setTimeout(() => console.log("C"), 0);
28setTimeout(() => console.log("D"), 50);
29console.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
39const fs = require("fs"); // Node.js example
40fs.readFile("file.txt", () => {
41 console.log("File read complete"); // macrotask
42});
43console.log("Reading file...");
44// Output: "Reading file..." then "File read complete"
SourceQueueExamples
TimersMacrotasksetTimeout, setInterval
I/OMacrotaskFile reads, network requests, database queries
UI EventsMacrotaskclick, scroll, keydown, resize
setImmediateMacrotaskNode.js only (similar to 0ms timer)
RenderingMacrotaskrequestAnimationFrame (special queue)
Microtask 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.

microtask-queue.js
JavaScript
1// Promise callbacks are microtasks
2console.log("Start");
3
4Promise.resolve().then(() => {
5 console.log("Promise 1");
6});
7
8Promise.resolve().then(() => {
9 console.log("Promise 2");
10});
11
12setTimeout(() => {
13 console.log("Timeout");
14}, 0);
15
16console.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
34console.log(1);
35queueMicrotask(() => console.log(2));
36console.log(3);
37// Output: 1, 3, 2
38
39// Microtask queue is FIFO
40queueMicrotask(() => console.log("A"));
41queueMicrotask(() => console.log("B"));
42// After current stack clears: A, B
43
44// MutationObserver callbacks are microtasks
45const target = document.createElement("div");
46const observer = new MutationObserver(() => console.log("DOM mutated"));
47observer.observe(target, { attributes: true });
48target.setAttribute("data-x", "1"); // triggers microtask

warning

Microtasks can starve the event loop. If you continuously add microtasks (e.g., chained promises in a loop), the task queue never gets processed — timers never fire, rendering never happens, and the UI freezes. Always ensure microtask chains have an exit condition.
Event Loop Phases

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.

Browser Event Loop Cycle
┌──────────────────────────────┐│ 1. Execute current task │ ← Macrotask or synchronous code└────────────┬─────────────────┘┌──────────────────────────────┐│ 2. Process ALL microtasks │ ← Promises, queueMicrotask│ (until queue empty) │└────────────┬─────────────────┘┌──────────────────────────────┐│ 3. Schedule rendering │ ← style recalc, layout, paint│ (if needed, ~60fps) │└────────────┬─────────────────┘ (repeat)
event-loop-phases.js
JavaScript
1// Event loop phase demonstration
2console.log("1: Sync start");
3
4setTimeout(() => {
5 console.log("2: setTimeout (macrotask)");
6}, 0);
7
8Promise.resolve()
9 .then(() => console.log("3: Promise microtask 1"))
10 .then(() => console.log("4: Promise microtask 2"));
11
12queueMicrotask(() => console.log("5: queueMicrotask"));
13
14console.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
29Promise.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});
38setTimeout(() => 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
preview
setTimeout(fn, 0) Trick

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.

settimeout-zero.js
JavaScript
1// setTimeout(fn, 0) — defers execution
2console.log("A");
3setTimeout(() => console.log("B"), 0);
4console.log("C");
5// A, C, B
6
7// Use case 1: breaking up long synchronous work
8function 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
25element.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
36function 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

setTimeout(fn, 0) is useful for deferring execution to avoid blocking, but prefer requestAnimationFrame for visual updates and requestIdleCallback for background work. In Node.js, setImmediate is a better alternative for deferring to the next iteration.
requestAnimationFrame

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.

requestanimationframe.js
JavaScript
1// Basic animation loop
2let position = 0;
3const element = document.getElementById("animated");
4
5function 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
14requestAnimationFrame(animate);
15
16// Timestamp argument
17let startTime = null;
18function 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}
29requestAnimationFrame(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)
36document.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
44requestAnimationFrame(() => console.log("A"));
45requestAnimationFrame(() => 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
MethodTimingBest For
requestAnimationFrameBefore paint (~60fps)Animations, visual updates, DOM measurements
setTimeout(fn, 0)Next macrotask iterationDeferring work, breaking up long tasks
requestIdleCallbackDuring idle periodsNon-critical background work
queueMicrotaskAfter current task, before rAF/paintPromise-like async, state consistency
requestIdleCallback

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.

requestidlecallback.js
JavaScript
1// Basic idle callback
2requestIdleCallback((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
8function 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
30requestIdleCallback(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
43if (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

Use requestIdleCallback for tasks that are neither time-sensitive nor user-visible: analytics batching, log persistence, preloading non-critical resources, and deferred UI reconciliation. For user-visible work (animations, layout changes), use requestAnimationFrame. For deferring work without a specific frame deadline, use setTimeout(fn, 0).
Blocking the Event Loop

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.

blocking.js
JavaScript
1// Blocking the event loop
2console.log("Start");
3
4// Block for 3 seconds
5const start = Date.now();
6while (Date.now() - start < 3000) {
7 // busy wait — blocks everything!
8}
9
10// This won't execute until 3 seconds later
11console.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
22function 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
48function nonBlockingPrimes(limit, chunkSize = 10000) {
49 // ... chunked approach with setTimeout yields
50}
51
52// Web Workers for heavy computation
53const worker = new Worker("prime-worker.js");
54worker.postMessage({ limit: 10000000 });
55worker.onmessage = (e) => {
56 console.log("Primes computed:", e.data);
57};
58console.log("Main thread is free during computation!");

danger

A blocked event loop is the #1 cause of poor user experience in web applications. Any synchronous operation taking longer than 50ms is considered a "Long Task" by performance tooling (Lighthouse, Chrome DevTools). For heavy computation, use Web Workers (browser) or Worker Threads (Node.js). For I/O, always use the async version.
Microtask Starvation

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.

microtask-starvation.js
JavaScript
1// Microtask starvation example
2function starve() {
3 // Each microtask adds another microtask — infinite loop!
4 Promise.resolve().then(() => {
5 starve();
6 });
7}
8starve();
9setTimeout(() => console.log("This never runs"), 0);
10// The macrotask queue is never reached!
11
12// Similar with queueMicrotask
13function starveQueue() {
14 queueMicrotask(() => {
15 starveQueue(); // keeps adding more microtasks
16 });
17}
18starveQueue();
19// Timers, I/O, and rendering are permanently blocked
20
21// Recursive promise chain
22function recursivePromise() {
23 return Promise.resolve().then(recursivePromise);
24}
25recursivePromise();
26// Microtask queue never empties — starvation!
27
28// Fix: periodically yield to macrotask queue
29function 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
39function 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

Microtask starvation is an insidious bug because the code looks correct — promises resolve as expected, but nothing else in the application works. Always ensure microtask chains have bounded depth. If you need to process a large queue, periodically defer to a macrotask using setTimeout or setImmediate.
Practical Debugging Tips

Debugging event loop issues requires understanding async execution order. Here are practical techniques for inspecting and debugging the event loop behavior in your code.

debugging-tips.js
JavaScript
1// 1. Trace execution order with markers
2console.log("1: Sync");
3Promise.resolve().then(() => console.log("2: Microtask"));
4setTimeout(() => console.log("3: Macrotask"), 0);
5console.log("4: Sync");
6// Helps verify your understanding of the queue order
7
8// 2. Use performance.now() for timing
9const start = performance.now();
10setTimeout(() => {
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
24function tracedPromise(name, fn) {
25 console.log(`${name}: started`);
26 return fn().then((result) => {
27 console.log(`${name}: resolved`);
28 return result;
29 });
30}
31tracedPromise("fetch", () => fetch("/data"))
32 .then(data => console.log("Data:", data));
33
34// 5. Detect long tasks
35const observer = new PerformanceObserver((list) => {
36 for (const entry of list.getEntries()) {
37 console.warn(`Long task: ${entry.duration}ms — ${entry.name}`);
38 }
39});
40observer.observe({ entryTypes: ["longtask"] });
41
42// 6. Event loop lag detection
43let lastCheck = performance.now();
44setInterval(() => {
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:

Use console.log markers to trace execution order of sync, microtask, and macrotask code
Use performance.now() to measure actual timer delays — large delays indicate a blocked event loop
Chrome DevTools Performance panel: record and identify long tasks, timer delays, and rAF clustering
Use PerformanceObserver to detect Long Tasks (>50ms) programmatically
Monitor event loop lag by comparing expected vs actual setInterval timing
Use Web Workers for heavy computation — they run in a separate thread and don't block the main loop
Break up long synchronous work into chunks with setTimeout(fn, 0) or scheduler.yield()
Avoid microtask starvation by bounding promise chains and yielding periodically to macrotasks
Use requestAnimationFrame for visual updates — it synchronizes with the browser's paint cycle
Use requestIdleCallback for non-critical background work during idle time
🔥

pro tip

The most important debugging skill for event loop issues: think in queues. When code runs out of order, trace the queues: "Is this synchronous? Is it a microtask (promise)? Is it a macrotask (timer/I/O)?" Understanding the queue priority (microtasks > rAF > macrotasks) explains nearly all async ordering mysteries.
$Blueprint — Engineering Documentation·Section ID: JS-EVENTLOOP·Revision: 1.0