JavaScript Event Loop: The Complete Guide for 2026
Every JavaScript developer eventually hits a moment where code does not execute in the order they expected. A setTimeout(..., 0) fires after a Promise callback. A long loop freezes the entire page. A click handler runs before a previously scheduled timer. The answer to all of these mysteries is the event loop — the engine behind JavaScript's concurrency model. This guide gives you a deep, practical understanding of how the event loop works in both browsers and Node.js, so you can write faster, more predictable, and truly non-blocking code.
Table of Contents
- What Is the Event Loop and Why It Matters
- The Call Stack Explained
- Web APIs, Callback Queue, and Microtask Queue
- Macrotasks vs Microtasks
- Event Loop Phases in Node.js
- Browser Event Loop vs Node.js Event Loop
- requestAnimationFrame and the Rendering Pipeline
- Common Pitfalls: Blocking the Main Thread
- The queueMicrotask() API
- Performance Patterns
- Debugging Event Loop Issues
- Real-World Examples
- Frequently Asked Questions
1. What Is the Event Loop and Why It Matters
JavaScript is a single-threaded language. It has one call stack, one memory heap, and it executes one operation at a time. Yet JavaScript powers some of the most interactive, responsive applications on the web. How? Through the event loop.
The event loop is a continuously running process that coordinates the execution of code, the collection and processing of events, and the execution of queued sub-tasks. It is what makes asynchronous programming possible in a single-threaded language. Without it, any network request, timer, or disk read would block the entire application.
Here is the simplified mental model:
- Run all synchronous code on the call stack until it is empty.
- Drain the entire microtask queue (Promise callbacks,
queueMicrotask). - Pick one macrotask from the macrotask queue (
setTimeout,setInterval, I/O). - Repeat from step 2.
Understanding this cycle is the key to predicting execution order, avoiding jank, and writing efficient async code. If you have ever been confused about why a setTimeout(..., 0) does not run "immediately," the event loop is the answer.
console.log('1 - synchronous');
setTimeout(() => console.log('2 - macrotask (setTimeout)'), 0);
Promise.resolve().then(() => console.log('3 - microtask (Promise)'));
console.log('4 - synchronous');
// Output:
// 1 - synchronous
// 4 - synchronous
// 3 - microtask (Promise)
// 2 - macrotask (setTimeout)
Even though setTimeout was called first, the Promise callback runs before it because microtasks have higher priority than macrotasks. This single example demonstrates the core principle of the event loop.
2. The Call Stack Explained
The call stack is a LIFO (Last In, First Out) data structure that tracks which function is currently executing and which functions called it. When you call a function, a stack frame is pushed onto the stack. When the function returns, its frame is popped off.
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }
printSquare(4);
// Stack: [] -> [printSquare] -> [printSquare, square]
// -> [printSquare, square, multiply] -> [printSquare, square]
// -> [printSquare] -> []
The event loop can only pick up the next task when the call stack is empty. This is why a long-running synchronous operation blocks everything: the stack never clears, so the event loop cannot process any queued callbacks, timers, or rendering updates.
// This blocks the event loop for ~5 seconds
function blockFor5Seconds() {
const start = Date.now();
while (Date.now() - start < 5000) {
// Busy wait: stack is never empty
}
}
setTimeout(() => console.log('Timer fired'), 100);
blockFor5Seconds();
// The timer callback won't run until blockFor5Seconds completes,
// even though 100ms has long passed
When the call stack overflows (too many nested calls without returning), you get the famous RangeError: Maximum call stack size exceeded. This typically happens with unbounded recursion.
3. Web APIs, Callback Queue, and Microtask Queue
The JavaScript engine itself (V8, SpiderMonkey, JavaScriptCore) only handles synchronous execution. All the async capabilities come from the runtime environment: the browser or Node.js provides Web APIs (or C++ APIs in Node) that handle async operations in the background.
Web APIs (Browser)
When you call setTimeout, fetch, addEventListener, or XMLHttpRequest, the JavaScript engine hands the operation off to the browser's Web API layer. The browser manages the timer, network request, or event listener on a separate thread. When the operation completes, the browser places the callback into the appropriate queue.
The Callback Queue (Macrotask Queue)
Also called the task queue or macrotask queue, this holds callbacks from setTimeout, setInterval, I/O events, UI events (clicks, scrolls), and MessageChannel. The event loop picks one macrotask per iteration.
The Microtask Queue
This holds callbacks from Promise.then/catch/finally, queueMicrotask(), and MutationObserver. After each macrotask (or after the initial script execution), the event loop drains the entire microtask queue before proceeding. New microtasks added during processing are also drained in the same cycle.
// Demonstrating queue priorities
console.log('Script start');
setTimeout(() => {
console.log('setTimeout 1');
}, 0);
Promise.resolve()
.then(() => {
console.log('Promise 1');
// Adding a new microtask from within a microtask
Promise.resolve().then(() => console.log('Promise 2'));
});
setTimeout(() => {
console.log('setTimeout 2');
}, 0);
console.log('Script end');
// Output:
// Script start
// Script end
// Promise 1
// Promise 2 -- microtask queue fully drained before any macrotask
// setTimeout 1
// setTimeout 2
This ordering is guaranteed by the specification. The microtask queue is always fully drained before the event loop moves on. This can be both powerful and dangerous — an infinite microtask loop will block the event loop just as effectively as a synchronous while loop.
4. Macrotasks vs Microtasks
Understanding the distinction between macrotasks and microtasks is the single most important concept for predicting JavaScript execution order.
Macrotasks (Task Queue)
setTimeout/setIntervalsetImmediate(Node.js only)- I/O callbacks
- UI rendering events
MessageChannel/postMessagerequestAnimationFrame(technically part of rendering, not the task queue)
Microtasks (Microtask Queue)
Promise.then/.catch/.finallyqueueMicrotask()MutationObserverprocess.nextTick()(Node.js — runs before other microtasks)
The Execution Order Rule
One complete cycle of the event loop works like this:
- Execute one macrotask (or the initial script).
- Drain all microtasks (including any microtasks generated during this step).
- Render (if needed — browser only).
- Go to step 1.
// Classic interview question: predict the output
setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => console.log('promise 1'));
Promise.resolve().then(() => {
console.log('promise 2');
setTimeout(() => console.log('timeout 3'), 0);
});
Promise.resolve().then(() => console.log('promise 3'));
// Output:
// promise 1
// promise 2
// promise 3
// timeout 1
// timeout 2
// timeout 3
All three promises resolve as microtasks before any setTimeout callback runs. The setTimeout scheduled inside promise 2's callback becomes the third macrotask, running last.
// Nested microtasks all drain before any macrotask
Promise.resolve().then(() => {
console.log('Microtask 1');
Promise.resolve().then(() => console.log('Microtask 2'));
});
setTimeout(() => console.log('Macrotask 1'), 0);
// Output: Microtask 1, Microtask 2, Macrotask 1
5. Event Loop Phases in Node.js
Node.js uses libuv under the hood, and its event loop has six distinct phases, each with its own FIFO queue of callbacks:
Timers: Executes setTimeout()/setInterval() callbacks. A timer specifies a minimum delay, not a guaranteed time. Pending callbacks: Deferred I/O callbacks like certain TCP errors. Poll: The most important phase — retrieves new I/O events and executes their callbacks. If the poll queue is empty, Node.js waits here or advances to check if setImmediate() callbacks are queued. Check: Executes setImmediate() callbacks, always after poll. Close callbacks: Handles close events like socket.on('close').
process.nextTick() vs setImmediate()
// process.nextTick fires BEFORE the event loop continues
// setImmediate fires in the check phase of the NEXT iteration
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
// Output:
// nextTick
// Promise
// setImmediate
process.nextTick() is not technically part of the event loop. It fires between phases, before any microtask (including Promise callbacks). Overusing it can starve the event loop just like recursive microtasks in the browser. The Node.js docs themselves recommend setImmediate() in most cases for clearer reasoning about execution order.
6. Browser Event Loop vs Node.js Event Loop
While both environments implement the event loop concept, there are key differences:
Browser Event Loop
- Has a macrotask queue, microtask queue, and a rendering pipeline.
- Rendering (style calculation, layout, paint) happens between macrotasks when needed, typically targeting 60fps (every ~16.6ms).
requestAnimationFramecallbacks run before paint.requestIdleCallbackruns during idle periods.- No
process.nextTick()orsetImmediate().
Node.js Event Loop
- Has the six-phase structure described above.
- No rendering pipeline (headless).
- Has
process.nextTick()(fires before microtasks between phases). - Has
setImmediate()(check phase). - I/O is the primary concern, not rendering.
Execution Order Difference
// Both environments: microtasks before macrotasks
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
// Output: promise, timeout (always, in both browser and Node.js 11+)
// Node.js-specific: setImmediate behavior depends on context
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Always: immediate, timeout (check phase runs before timers after poll)
});
// Top level: setTimeout vs setImmediate is non-deterministic!
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Could be either order -- depends on process startup time
The top-level setTimeout vs setImmediate race condition is a classic Node.js gotcha. Inside an I/O callback, setImmediate always fires first because the check phase comes right after poll.
7. requestAnimationFrame and the Rendering Pipeline
requestAnimationFrame(callback) is special. It is not a macrotask and not a microtask. It runs at a specific point in the browser's rendering cycle: before the paint step, after style recalculation and layout.
The browser rendering pipeline per frame:
- Run one macrotask.
- Drain all microtasks.
- If it is time to render (~16.6ms for 60fps): run
requestAnimationFramecallbacks, recalculate styles, layout, and paint. - If idle time remains: run
requestIdleCallbackcallbacks.
// rAF fires once per frame, right before paint
function animate() {
element.style.transform = `translateX(${position}px)`;
position += 2;
if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
// Why rAF instead of setTimeout?
// - setTimeout(fn, 16) doesn't sync with the display refresh rate
// - rAF automatically matches the display refresh (60Hz, 120Hz, 144Hz)
// - rAF is paused when the tab is in the background (saves battery)
// - rAF batches all DOM reads/writes into one frame
rAF vs Microtasks vs Macrotasks
// Execution order with rAF
console.log('sync');
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('microtask'));
setTimeout(() => console.log('macrotask'), 0);
// Typical output:
// sync
// microtask
// macrotask -- may run before or after rAF depending on frame timing
// rAF -- runs when the browser is ready to paint
The exact position of requestAnimationFrame relative to setTimeout depends on frame timing. But it is always after microtasks and before the actual paint. For animations, always use requestAnimationFrame instead of setTimeout — it results in smoother visuals and better performance.
8. Common Pitfalls: Blocking the Main Thread
Because JavaScript is single-threaded, any synchronous operation that takes too long will block the event loop and freeze everything: UI updates, user interaction, timers, and network responses.
Pitfall 1: Long Synchronous Loops
// BAD: blocks the main thread for huge arrays
function processAll(arr) {
return arr.map(item => expensiveComputation(item));
}
// GOOD: yield to the event loop between chunks
async function processAllAsync(arr, chunkSize = 1000) {
const results = [];
for (let i = 0; i < arr.length; i += chunkSize) {
results.push(...arr.slice(i, i + chunkSize).map(expensiveComputation));
await new Promise(resolve => setTimeout(resolve, 0)); // Yield
}
return results;
}
Pitfall 2: Recursive Microtask Starvation
// DANGER: infinite microtask loop blocks EVERYTHING
function recurse() {
Promise.resolve().then(recurse);
}
recurse();
// The microtask queue never empties
// No macrotasks, rendering, or user events can fire
// The page is completely frozen
Pitfall 3: Layout Thrashing
// BAD: reading then writing DOM in a loop forces synchronous layout
for (const el of elements) {
const height = el.offsetHeight; // Forces layout
el.style.height = (height * 2) + 'px'; // Invalidates layout
}
// GOOD: batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => {
el.style.height = (heights[i] * 2) + 'px';
});
9. The queueMicrotask() API
queueMicrotask(callback) schedules a function to run as a microtask. It was introduced to provide a direct, intentional way to queue microtasks without the overhead of creating a Promise.
// Before queueMicrotask, developers used this hack:
Promise.resolve().then(() => {
// Runs as a microtask, but creates a Promise object unnecessarily
});
// With queueMicrotask (cleaner, more intentional):
queueMicrotask(() => {
// Runs as a microtask, no Promise overhead
});
When to Use queueMicrotask()
Consistent execution order: When an API can return both synchronously and asynchronously, use queueMicrotask() to ensure the callback always fires asynchronously:
// Problem: cache hit is sync, cache miss is async -- inconsistent!
function getData(key, callback) {
if (cache.has(key)) {
callback(cache.get(key)); // Sync
} else {
fetch(`/api/${key}`).then(r => r.json()).then(callback); // Async
}
}
// Solution: queueMicrotask makes the cache path consistently async
function getData(key, callback) {
if (cache.has(key)) {
queueMicrotask(() => callback(cache.get(key))); // Always async
} else {
fetch(`/api/${key}`).then(r => r.json()).then(callback);
}
}
Batching updates: Collect multiple synchronous changes and process them in one microtask:
let pending = false;
const updates = [];
function scheduleUpdate(update) {
updates.push(update);
if (!pending) {
pending = true;
queueMicrotask(() => {
const batch = updates.splice(0);
pending = false;
applyUpdates(batch); // All updates processed at once
});
}
}
scheduleUpdate({ type: 'add', id: 1 });
scheduleUpdate({ type: 'add', id: 2 });
scheduleUpdate({ type: 'remove', id: 3 });
// All three run in a single flush
10. Performance Patterns
Pattern 1: Chunking Work with setTimeout
function processInChunks(items, processFn, chunkSize = 100) {
let i = 0;
function next() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) processFn(items[i]);
if (i < items.length) setTimeout(next, 0); // Yield
}
next();
}
Pattern 2: Web Workers for CPU-Intensive Tasks
// main.js -- offload heavy work to a separate thread
const worker = new Worker('heavy-computation.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => console.log('Result:', e.data);
// heavy-computation.js (runs on separate thread)
self.onmessage = (event) => {
const sorted = event.data.data.sort((a, b) => a - b);
self.postMessage(sorted);
};
Pattern 3: requestIdleCallback for Non-Urgent Work
// Schedule work during browser idle periods
function sendAnalytics(data) {
requestIdleCallback((deadline) => {
while (data.length > 0 && deadline.timeRemaining() > 5) {
const event = data.shift();
navigator.sendBeacon('/analytics', JSON.stringify(event));
}
if (data.length > 0) {
requestIdleCallback(() => sendAnalytics(data));
}
}, { timeout: 5000 });
}
11. Debugging Event Loop Issues
Chrome DevTools Performance Panel
The Performance panel in Chrome DevTools is the best tool for visualizing event loop behavior. Record a performance profile and look for:
- Long Tasks (red corner indicators) — any task longer than 50ms blocks user interaction.
- Forced reflows — synchronous layout recalculations triggered by reading layout properties after DOM writes.
- Frame drops — gaps in the frame chart indicate the main thread was too busy.
The Long Task API
// Programmatically detect long tasks (>50ms)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long task detected:', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name
});
}
});
observer.observe({ type: 'longtask', buffered: true });
Measuring Event Loop Lag (Browser)
function measureEventLoopLag(interval = 1000) {
let lastTime = performance.now();
setInterval(() => {
const now = performance.now();
const lag = now - lastTime - interval;
if (lag > 50) console.warn(`Event loop lag: ${lag.toFixed(1)}ms`);
lastTime = now;
}, interval);
}
Node.js: monitorEventLoopDelay
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log({ min: h.min/1e6, max: h.max/1e6, mean: h.mean/1e6, p99: h.percentile(99)/1e6 });
h.reset();
}, 5000);
12. Real-World Examples
Example 1: Debounced Search with Event Loop Awareness
function createDebouncedSearch(searchFn, delay = 300) {
let timeoutId = null;
let controller = null;
return function(query) {
clearTimeout(timeoutId); // Cancel previous macrotask
controller?.abort(); // Cancel in-flight fetch
timeoutId = setTimeout(async () => {
controller = new AbortController();
try {
const results = await searchFn(query, controller.signal);
renderResults(results);
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}, delay);
};
}
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
Example 2: Understanding async/await in the Event Loop
// async/await is just syntax sugar over Promises + microtasks
async function example() {
console.log('A - sync (before await)');
await Promise.resolve();
// Everything after await is a microtask (like .then())
console.log('B - microtask (after await)');
}
console.log('1 - sync');
example();
console.log('2 - sync');
// Output:
// 1 - sync
// A - sync (before await)
// 2 - sync
// B - microtask (after await)
// The code after 'await' is enqueued as a microtask.
// It runs after all synchronous code completes.
Frequently Asked Questions
What is the JavaScript event loop and why does it matter?
The event loop is the mechanism that allows JavaScript to perform non-blocking operations despite being single-threaded. It continuously checks whether the call stack is empty, and if so, picks up the next task from the microtask queue or macrotask queue and pushes it onto the stack. Without the event loop, JavaScript could not handle timers, network requests, or user interactions without freezing the entire page. Understanding it is essential for writing performant, bug-free async code.
What is the difference between microtasks and macrotasks?
Microtasks include Promise callbacks (.then, .catch, .finally), queueMicrotask(), and MutationObserver callbacks. Macrotasks include setTimeout, setInterval, setImmediate (Node.js), I/O operations, and UI rendering events. The critical difference is priority: after each macrotask completes, the event loop drains the entire microtask queue before picking up the next macrotask. This means Promise.resolve().then(fn) always executes before setTimeout(fn, 0).
How does the Node.js event loop differ from the browser event loop?
The Node.js event loop has six distinct phases: timers (setTimeout/setInterval callbacks), pending callbacks (deferred I/O callbacks), idle/prepare (internal), poll (retrieve new I/O events), check (setImmediate callbacks), and close callbacks. The browser event loop is simpler with a macrotask queue, microtask queue, and rendering steps. Node.js also has process.nextTick() which runs before any other microtask, and setImmediate() which runs in the check phase after I/O.
How can I avoid blocking the main thread in JavaScript?
Break long-running computations into smaller chunks using setTimeout or requestAnimationFrame to yield control back to the event loop between chunks. For CPU-intensive work, use Web Workers which run on separate threads. You can also use requestIdleCallback to run non-urgent work during browser idle periods. In modern code, the scheduler.yield() proposal and the isInputPending() API help you yield only when the user is trying to interact with the page.
What is queueMicrotask() and when should I use it?
queueMicrotask(fn) schedules a function to run as a microtask, which means it executes after the current synchronous code completes but before the browser renders or any macrotask runs. Use it when you need to defer work until after the current execution context but before rendering, such as batching DOM updates or ensuring consistent ordering of synchronous and asynchronous code paths. It is lighter than Promise.resolve().then(fn) since it does not create a Promise object.
Conclusion
The event loop is the heart of JavaScript's concurrency model. It is the reason a single-threaded language can power the most interactive applications on the web and handle thousands of concurrent connections on the server. By understanding how the call stack, microtask queue, and macrotask queue interact, you gain the ability to predict execution order, avoid jank, and write code that cooperates with the runtime instead of fighting it.
The three most important takeaways: (1) microtasks always run before macrotasks — this is why Promise.then() fires before setTimeout(..., 0), (2) never block the call stack with synchronous work — use chunking, Web Workers, or requestIdleCallback for heavy computation, and (3) use requestAnimationFrame for visual updates — it syncs with the browser's rendering pipeline for smooth 60fps animations.
Related Resources
- JavaScript Promises & Async/Await Guide — master Promises, async/await, error handling, and advanced async patterns
- Node.js Complete Guide — comprehensive guide to Node.js including the event loop in a server context
- JavaScript Debugging Complete Guide — tools and techniques for debugging async and event loop issues
- Web Performance Optimization Guide — optimize rendering, loading, and runtime performance
- JavaScript Runner — run and test JavaScript code snippets instantly in your browser
- JS Playground — full-featured JavaScript coding environment with console output
Keep learning: The event loop is deeply connected to async programming patterns. Read our Promises & Async/Await Guide to master the async primitives that the event loop orchestrates, and check out our Node.js Complete Guide for server-side event loop specifics.