The event loop is the mechanism that allows JavaScript — a single-threaded language — to be non-blocking. Understanding it explains why async code behaves the way it does, why some promises resolve before timeouts, and why long synchronous operations freeze the browser.
The Call Stack and the Execution Model
JavaScript executes code on a single thread. Synchronous code goes directly onto the call stack. Function calls push frames onto the stack; returns pop them off. The key constraint: nothing else runs while the call stack is occupied.
When an async operation (network request, timer, I/O) finishes, it doesn’t immediately jump back into execution. Instead, a callback is placed in a queue. The event loop’s job is to pick items off that queue and put them on the call stack — but only when the stack is empty.
function first() { console.log('First'); second(); }
function second() { console.log('Second'); third(); }
function third() { console.log('Third'); }
first();
// Call stack: first → second → third → (unwind)
// Output: First, Second, Third
When the entire script finishes executing synchronously, the call stack empties and the event loop can process queued callbacks.
Two Queues: Microtasks and Macrotasks
Not all async callbacks are equal. JavaScript has two distinct queues with different priorities:
| Queue | Contents | When processed |
|---|---|---|
| Microtask queue | Resolved Promises (.then/.catch/.finally), queueMicrotask(), MutationObserver |
After every task, before rendering |
| Macrotask queue | setTimeout, setInterval, setImmediate (Node.js), I/O events |
One task per loop iteration |
The critical rule: all microtasks are drained before the next macrotask runs. This is why Promise callbacks always execute before setTimeout callbacks, even a setTimeout(fn, 0):
console.log('Script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'));
console.log('Script end');
// Output:
// Script start ← synchronous
// Script end ← synchronous
// Promise 1 ← microtask (before any macrotask)
// Promise 2 ← microtask (chained .then creates another microtask)
// setTimeout ← macrotask (runs after microtask queue is empty)
The Full Execution Order
The event loop algorithm in plain terms:
- Execute all synchronous code on the call stack
- Drain the microtask queue completely (keep running microtasks until the queue is empty)
- Render if needed (browser only)
- Pick one macrotask from the queue and execute it
- Go to step 2
console.log('1. Synchronous');
setTimeout(() => {
console.log('2. Macrotask (setTimeout)');
// A Promise inside a macrotask creates a microtask
Promise.resolve().then(() => console.log('3. Microtask inside macrotask'));
}, 0);
Promise.resolve().then(() => {
console.log('4. Microtask');
// A setTimeout inside a microtask creates a new macrotask
setTimeout(() => console.log('5. Macrotask created from microtask'), 0);
});
console.log('6. Synchronous');
// Output:
// 1. Synchronous
// 6. Synchronous
// 4. Microtask ← microtask queue drained first
// 2. Macrotask (setTimeout) ← first macrotask
// 3. Microtask inside macrotask ← microtask queue drained after macrotask
// 5. Macrotask created from microtask ← second macrotask
Work through this output mentally — it’s the single most important exercise for understanding the event loop.
queueMicrotask
queueMicrotask() explicitly schedules a microtask without wrapping it in a Promise. It runs with the same priority as Promise callbacks:
console.log('Start');
queueMicrotask(() => console.log('Microtask via queueMicrotask'));
Promise.resolve().then(() => console.log('Microtask via Promise'));
setTimeout(() => console.log('Macrotask'), 0);
console.log('End');
// Output:
// Start
// End
// Microtask via queueMicrotask (execution order between these two depends on insertion order)
// Microtask via Promise
// Macrotask
queueMicrotask is useful when you need to schedule high-priority work without creating an unnecessary Promise wrapper.
The Main Thread Blocking Problem
Since JavaScript is single-threaded, anything that occupies the call stack blocks everything else — rendering, user input, other callbacks. This is the “blocking the main thread” problem:
// ❌ This freezes the browser for 2 seconds — no UI updates, no input handling
const start = performance.now();
while (performance.now() - start < 2000) {
// Busy wait — the event loop cannot process anything during this time
}
console.log('Done');
// ✅ Break long work into chunks using setTimeout or requestIdleCallback
function processChunk(items, index = 0) {
const CHUNK_SIZE = 100;
const end = Math.min(index + CHUNK_SIZE, items.length);
for (let i = index; i < end; i++) {
doWork(items[i]);
}
if (end < items.length) {
// Yield to the event loop between chunks so the UI stays responsive
setTimeout(() => processChunk(items, end), 0);
}
}
Microtask Starvation
Because microtasks run to completion before any macrotask, infinite microtask recursion starves the macrotask queue (and the render loop):
// ❌ This creates infinite microtasks — setTimeout never runs
function infiniteMicrotasks() {
Promise.resolve().then(() => infiniteMicrotasks());
}
infiniteMicrotasks();
setTimeout(() => console.log('This never runs'), 0);
// ✅ Use macrotasks for work that should yield to the event loop
function limitedWork(count = 0) {
if (count >= 10) return;
Promise.resolve().then(() => limitedWork(count + 1));
}
Batching Updates with Microtasks
React and other UI libraries use microtasks to batch state updates — schedule all updates as microtasks, then flush them together before rendering:
class BatchUpdater {
constructor() {
this.pendingUpdates = [];
this.scheduled = false;
}
schedule(update) {
this.pendingUpdates.push(update);
if (!this.scheduled) {
this.scheduled = true;
// Flush after all synchronous code (and other microtasks) run
queueMicrotask(() => this.flush());
}
}
flush() {
const updates = this.pendingUpdates.splice(0);
this.scheduled = false;
console.log(`Flushing ${updates.length} batched updates`);
updates.forEach(fn => fn());
}
}
const updater = new BatchUpdater();
updater.schedule(() => console.log('Update 1'));
updater.schedule(() => console.log('Update 2'));
updater.schedule(() => console.log('Update 3'));
// All three are collected first, then flushed in one microtask
// Output: Flushing 3 batched updates, Update 1, Update 2, Update 3
Measuring Event Loop Lag
In production Node.js servers, event loop lag indicates when your application is becoming unresponsive. A lag over 50ms typically means something is blocking the thread:
function measureEventLoopLag() {
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const lag = now - lastCheck - 1000; // Expected interval: 1000ms
if (lag > 50) {
console.warn(`Event loop lag: ${lag}ms — something is blocking the thread`);
}
lastCheck = now;
}, 1000);
}
Summary
The event loop is why JavaScript can be asynchronous despite running on a single thread. The key rules:
- Synchronous code runs first and blocks everything while it runs
- Microtasks (Promises,
queueMicrotask) run after each task and before rendering - Macrotasks (
setTimeout,setInterval) run one per event loop iteration - All microtasks drain before the next macrotask — never
setTimeout(fn, 0)to “run before promises” - Long synchronous operations block the entire thread; break them into chunks with
setTimeout - Infinite microtask recursion starves macrotasks and the render loop
Resources
- MDN: Event loop
- MDN: queueMicrotask()
- Jake Archibald: In The Loop (talk)
- JavaScript.info: Event loop
Comments