Skip to main content
Advertisement

Understanding the Event Loop

JavaScript is a single-threaded language. But how does it handle asynchronous tasks? The secret lies in the Event Loop.


What Is the Event Loop?

The JavaScript runtime runs on a single thread, but the browser or Node.js runtime environment provides additional APIs and threads that enable asynchronous processing.

The event loop is the mechanism that coordinates all of this.


Runtime Structure

┌──────────────────────────────────────────────────────────┐
│ JavaScript Runtime │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────┐ │
│ │ Call Stack │ │ Web APIs │ │
│ │ │ │ (setTimeout, fetch, DOM Events) │ │
│ │ main() │ └──────────────┬───────────────────┘ │
│ │ foo() │ │ │
│ │ bar() │ ┌──────────────▼───────────────────┐ │
│ └──────┬──────┘ │ Macrotask Queue │ │
│ │ │ [setTimeout cb, setInterval cb] │ │
│ │ └──────────────┬───────────────────┘ │
│ │ │ │
│ │ ┌──────────────▼───────────────────┐ │
│ │ │ Microtask Queue │ │
│ │ │ [Promise.then, queueMicrotask] │ │
│ │ └──────────────┬───────────────────┘ │
│ │ │ │
│ └──────────── Event Loop ──┘ │
└──────────────────────────────────────────────────────────┘

Key Components

1. Call Stack

The stack of currently executing functions. Operates in LIFO (Last In, First Out) order.

function greet(name) {
return `Hello, ${name}!`;
}

function main() {
const message = greet('World');
console.log(message);
}

main();
// Call Stack changes:
// 1. main() pushed
// 2. greet('World') pushed
// 3. greet returns → popped
// 4. console.log pushed → runs → popped
// 5. main returns → popped

2. Web APIs (Browser-provided APIs)

Async APIs provided by the browser, such as setTimeout, fetch, and DOM Events. Handled on separate threads.

// Things handled by Web APIs
setTimeout(() => console.log('timeout'), 1000); // Timer API
fetch('https://api.example.com/data'); // Network API
document.addEventListener('click', handler); // DOM Events API

3. Macrotask Queue

Queue where callbacks are placed after Web API operations complete.

Macrotask examples:

  • setTimeout
  • setInterval
  • setImmediate (Node.js)
  • I/O callbacks
  • UI rendering

4. Microtask Queue

Queue for Promise .then(), .catch(), .finally() callbacks and queueMicrotask() callbacks.

Microtask examples:

  • Promise.then/catch/finally
  • queueMicrotask()
  • MutationObserver
  • async/await (uses Promise internally)

How the Event Loop Works

The event loop repeats in this order:

1. Check if Call Stack is empty
2. Process Microtask Queue (drain completely)
3. Pick one item from Macrotask Queue and run it
4. Process Microtask Queue again
5. Go back to 1 and repeat
// Event loop example
console.log('1: sync code'); // runs immediately

setTimeout(() => console.log('2: setTimeout'), 0); // Macrotask Queue

Promise.resolve().then(() => console.log('3: Promise.then')); // Microtask Queue

console.log('4: sync code'); // runs immediately

// Output order:
// 1: sync code
// 4: sync code
// 3: Promise.then ← Microtask first!
// 2: setTimeout ← Macrotask later

Microtask vs Macrotask Priority

Microtasks always run before macrotasks.

console.log('start');

// Macrotask
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => console.log('Promise inside setTimeout'));
}, 0);

setTimeout(() => console.log('setTimeout 2'), 0);

// Microtask
Promise.resolve()
.then(() => {
console.log('Promise 1');
return 'Promise 1 value';
})
.then(() => console.log('Promise 2'));

queueMicrotask(() => console.log('queueMicrotask'));

console.log('end');

// Output order:
// start
// end
// Promise 1 ← Microtask
// queueMicrotask ← Microtask
// Promise 2 ← Microtask (created from previous .then)
// setTimeout 1 ← Macrotask
// Promise inside setTimeout ← Microtask (after setTimeout runs)
// setTimeout 2 ← Macrotask

Deep Dive: Execution Order

// Complex mixed example
async function asyncFunc() {
console.log('async function start'); // 2
await Promise.resolve();
console.log('after await'); // 5
}

console.log('script start'); // 1

setTimeout(() => console.log('setTimeout'), 0); // 7

asyncFunc();

Promise.resolve().then(() => console.log('Promise.then')); // 4

console.log('script end'); // 3

// Output:
// script start (1)
// async function start (2) - runs synchronously until first await
// script end (3)
// Promise.then (4) - Microtask
// after await (5) - await is internally Promise.then
// setTimeout (6) - Macrotask

requestAnimationFrame Position

requestAnimationFrame has a special position between macrotasks and microtasks.

// requestAnimationFrame runs before rendering
console.log('start');

setTimeout(() => console.log('setTimeout'), 0);

requestAnimationFrame(() => console.log('rAF'));

Promise.resolve().then(() => console.log('Promise'));

// Typical output order (browser):
// start
// Promise ← Microtask
// rAF ← Before rendering (may vary by browser)
// setTimeout ← Macrotask

// rAF typically runs at ~16ms intervals (60fps)
function animate() {
updatePosition();
requestAnimationFrame(animate); // schedule next frame
}
requestAnimationFrame(animate);

Node.js Event Loop

Node.js has a more granular event loop with multiple phases.

   ┌───────────────────────────┐
┌─>│ timers │ ← setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ ← I/O error callbacks from previous loop
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ ← internal use
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ poll │ ← wait/process I/O events
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ ← setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──┤ close callbacks │ ← socket.close etc
└───────────────────────────┘
// setImmediate vs setTimeout in Node.js
const { performance } = require('node:perf_hooks');

// Inside I/O callbacks, setImmediate always runs before setTimeout
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'));
setImmediate(() => console.log('setImmediate'));
// Always: setImmediate → setTimeout
});

// Outside I/O, order is not guaranteed
setTimeout(() => console.log('timeout'));
setImmediate(() => console.log('immediate'));
// Order depends on system timing

// process.nextTick runs before everything (even before Microtasks!)
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Output: nextTick → Promise

Stack Overflow and Solutions

// Stack overflow-inducing code (don't do this)
function infiniteRecursion() {
return infiniteRecursion(); // Maximum call stack size exceeded
}

// Solution: distribute stack with async
function safeRecursion(n) {
if (n <= 0) return;
setTimeout(() => safeRecursion(n - 1), 0);
}

// Better: use queueMicrotask or Promise
async function asyncRecursion(n) {
if (n <= 0) return;
await Promise.resolve(); // yield the stack
return asyncRecursion(n - 1);
}

Real-world Example: Monitoring the Event Loop

// Measure event loop lag (Node.js)
let lastCheck = Date.now();

setInterval(() => {
const now = Date.now();
const delay = now - lastCheck - 1000;

if (delay > 50) {
console.warn(`Event loop lag detected: ${delay}ms`);
}

lastCheck = now;
}, 1000);

// Bad example: blocking the event loop
function blockingOperation() {
const start = Date.now();
while (Date.now() - start < 500) {
// Blocks for 500ms! No async work can run
}
}

// Correct approach: split work into chunks
async function nonBlockingOperation(items) {
const CHUNK_SIZE = 100;

for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk);

// Yield control back to the event loop
await new Promise(resolve => setTimeout(resolve, 0));
}
}

Pro Tips

1. Beware of infinite microtask loops

// Dangerous: Microtask queue never empties, UI freezes
function dangerousMicrotaskLoop() {
Promise.resolve().then(dangerousMicrotaskLoop);
// Browser can't render!
}

// Safe: Use setTimeout for macrotask loop
function safeMacrotaskLoop() {
setTimeout(safeMacrotaskLoop, 0);
// Each iteration gives rendering a chance
}

2. Offload CPU-intensive work to Web Workers

// Heavy work on the main thread → blocks event loop
// Solution: Web Worker
const worker = new Worker('heavy-computation.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => {
console.log('Result:', e.data.result);
};

3. Promise chaining accumulates microtasks

// Each .then() creates a new microtask
Promise.resolve()
.then(() => 1) // Microtask 1
.then(() => 2) // Microtask 2 (created after 1 completes)
.then(() => 3) // Microtask 3 (created after 2 completes)
.then(console.log);

// Multiple .then() on the same Promise queue simultaneously
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
// A → B (in registration order)
Advertisement