Understanding the JavaScript event loop is fundamental to writing non-blocking code, debugging async issues, and understanding why things execute in a particular order. This guide explains the event loop from first principles, with practical examples you can run in Firefox's console.
The JavaScript Runtime Model
JavaScript has a single call stack. Only one thing executes at a time. Yet it handles asynchronous operations (setTimeout, fetch, addEventListener) without blocking. How?
Three components:
- Call Stack: tracks function execution. LIFO (last in, first out).
- Task Queue (Macrotask Queue): queues callbacks from setTimeout, setInterval, I/O, UI events
- Microtask Queue: queues Promise callbacks (.then, .catch, async/await resumes), queueMicrotask()
The event loop repeatedly:
- Executes everything in the call stack until empty
- Drains the entire microtask queue (runs all microtasks, including new ones added during microtask execution)
- Takes ONE task from the task queue (runs it completely, then repeats from step 2)
- Updates the rendering (if in a browser)
Visualizing Execution Order
console.log('1 - synchronous');
setTimeout(() => console.log('2 - setTimeout (task queue)'), 0);
Promise.resolve().then(() => console.log('3 - promise microtask'));
queueMicrotask(() => console.log('4 - queueMicrotask'));
console.log('5 - synchronous');
Output:
1 - synchronous
5 - synchronous
3 - promise microtask
4 - queueMicrotask
2 - setTimeout (task queue)
Why: sync code runs first (1, 5), then all microtasks (3, 4), then the setTimeout task (2).
Microtasks vs. Macrotasks
// MICROTASKS: Promise.then, Promise.catch, queueMicrotask
// - Run immediately after current task (before next macrotask)
// - Can block rendering if they queue indefinitely
// MACROTASKS: setTimeout, setInterval, UI events, fetch callbacks
// - Run one at a time, with microtask drain + rendering between each
// Example: Promise chains are all microtasks
async function example() {
console.log('A');
await Promise.resolve(); // queues a microtask
console.log('B'); // runs in microtask
await Promise.resolve(); // queues another microtask
console.log('C'); // runs in microtask
}
setTimeout(() => console.log('timeout'), 0);
example();
console.log('sync after example()');
Output:
A
sync after example()
B
C
timeout
The Rendering Connection
The browser renders between macrotasks (usually). Long-running synchronous code or microtask chains prevent rendering (UI freeze).
// BAD: blocks rendering for 500ms
function blockMainThread() {
const start = performance.now();
while (performance.now() - start < 500) {} // spin loop
}
blockMainThread(); // Page freezes for 500ms
// GOOD: yield to rendering between chunks
async function processInChunks(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 100 === 0) {
// Yield to event loop — allows rendering
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
async/await and the Event Loop
async/await is syntactic sugar over Promises. Each await suspends the function and schedules the rest as a microtask:
async function fetchUser(id) {
console.log('1: fetch start');
const user = await fetch(`/api/users/${id}`); // suspends here
// Everything below this is a microtask after fetch resolves
console.log('3: after fetch'); // might run much later
return user.json();
}
fetchUser(1);
console.log('2: after calling fetchUser'); // runs synchronously
Output order: 1, 2, then later 3 (after the network request completes).
Why setInterval is Unreliable for Precision Timing
// setInterval guarantees MINIMUM delay, not exact delay
// Real delay = max(specified, time_to_drain_current_task + microtasks)
setInterval(() => {
console.log('tick', Date.now());
}, 16); // ~60fps... but only if main thread isn't busy
// If your code takes 50ms to run, the 16ms interval will fire every 50ms+
For animation: always use requestAnimationFrame (runs before paint, synced to display refresh).
Debugging Event Loop Issues in Firefox
Long Task detection in Performance profiler:
- DevTools → Performance → Record
- Perform the slow action
- Stop recording
- Look for long yellow "Scripting" blocks (> 50ms are "Long Tasks")
Direct observation:
// Detect microtask queue flooding
let microtaskCount = 0;
const originalThen = Promise.prototype.then;
Promise.prototype.then = function(...args) {
microtaskCount++;
return originalThen.call(this, ...args);
};
setInterval(() => {
console.log('Microtasks created in last second:', microtaskCount);
microtaskCount = 0;
}, 1000);
The Node.js Event Loop (For Comparison)
Node.js has a similar but different event loop with additional phases:
- timers (setTimeout, setInterval)
- pending callbacks
- idle, prepare
- poll (I/O events)
- check (setImmediate)
- close callbacks
setImmediate in Node.js is like setTimeout(fn, 0) but with different priority in the event loop. There's no setImmediate in Firefox (browser).
Practical: Ensuring UI Updates During Long Operations
async function processLargeDataset(dataset) {
const chunkSize = 1000;
const results = [];
for (let i = 0; i < dataset.length; i += chunkSize) {
const chunk = dataset.slice(i, i + chunkSize);
// Process chunk
results.push(...processChunk(chunk));
// Update progress UI (yields to rendering event loop)
updateProgressBar((i / dataset.length) * 100);
// Yield to allow rendering + user interaction
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
This pattern processes data in chunks, yielding after each chunk to allow the browser to render progress updates and respond to user input.
Firefox's Implementation Detail
Firefox's SpiderMonkey engine implements the event loop per the HTML Living Standard (same as other browsers). Microtasks are processed via the "perform a microtask checkpoint" algorithm.
One Firefox-specific behavior: Firefox processes microtasks slightly more eagerly than Chrome in some edge cases involving Promise resolution and DOM mutations simultaneously. For almost all practical code this doesn't matter, but it can surface bugs in code that depends on exact ordering of async operations.
Top comments (0)