Skip to content

Understanding the JavaScript Event Loop

The event loop is the engine behind every async operation in JavaScript — here's how it actually works, beyond the hand-wavy explanations.

3 min read
Diagram of the JavaScript event loop showing call stack, task queue, and microtask queue

Most JavaScript developers use async/await and Promises daily without understanding the machinery underneath. That works until you hit a bug where callbacks fire in unexpected order, setTimeout(fn, 0) doesn't run immediately, or a microtask starves the rendering pipeline. Understanding the event loop turns these mysteries into predictable behavior.

The Call Stack and Task Queue

JavaScript is single-threaded. One call stack, one piece of code executing at a time. When async operations complete, their callbacks don't interrupt the current execution — they join a queue and wait.

jsjavascript
console.log("1");
 
setTimeout(() => {
  console.log("2");
}, 0);
 
console.log("3");
 
// Output: 1, 3, 2
// "2" goes to the task queue, runs after the current stack empties

Even with a timeout of 0, the callback waits until the call stack is empty. The event loop's job is checking: "Is the stack empty? If yes, pick the next task from the queue."

Microtasks vs. Macrotasks

Not all queued work is equal. Promises and queueMicrotask go to the microtask queue. setTimeout, setInterval, and I/O callbacks go to the macrotask queue. Microtasks drain completely before the next macrotask runs.

jsjavascript
console.log("start");
 
setTimeout(() => console.log("timeout"), 0);
 
Promise.resolve().then(() => console.log("promise 1"));
 
queueMicrotask(() => {
  console.log("microtask");
  Promise.resolve().then(() => console.log("promise 2"));
});
 
console.log("end");
 
// Output: start, end, promise 1, microtask, promise 2, timeout
tstypescript
// ❌ Assuming setTimeout and Promise.then have the same priority
function processItems(items: string[]) {
  items.forEach((item) => {
    setTimeout(() => sendToAnalytics(item), 0);
  });
  // Bug: if you resolve a Promise after this, it runs
  // BEFORE any analytics calls
}
 
// ✅ Understanding the queue priority
function processItems(items: string[]) {
  // Use the same queue type for ordering guarantees
  items.forEach((item) => {
    queueMicrotask(() => sendToAnalytics(item));
  });
}

The priority order per event loop tick: drain all microtasks → run one macrotask → drain all microtasks again → repeat.

The Rendering Pipeline

In browsers, the event loop coordinates with the rendering engine. Between macrotasks, the browser can repaint — but only if the microtask queue is empty.

jsjavascript
// ❌ Microtask loop blocks rendering indefinitely
function recursiveMicrotask() {
  queueMicrotask(() => {
    // This starves the render pipeline — UI freezes
    doExpensiveWork();
    recursiveMicrotask();
  });
}
 
// ✅ Use macrotasks to yield to the renderer
function chunkedWork(items: unknown[], index = 0) {
  const CHUNK_SIZE = 100;
  const end = Math.min(index + CHUNK_SIZE, items.length);
 
  for (let i = index; i < end; i++) {
    processItem(items[i]);
  }
 
  if (end < items.length) {
    // setTimeout yields to the render pipeline between chunks
    setTimeout(() => chunkedWork(items, end), 0);
  }
}

If your JavaScript blocks the main thread for more than 16ms, you miss a frame. Users perceive this as jank or freezes.

Node.js: Additional Phases

Node.js extends the browser event loop with additional phases. The order matters for server-side code.

   ┌───────────────────────────┐
┌─→│        timers              │ ← setTimeout, setInterval
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     pending callbacks      │ ← I/O callbacks deferred
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │        poll                │ ← I/O events, incoming connections
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │        check               │ ← setImmediate
│  └───────────┬───────────────┘
│  ┌───────────┴───────────────┐
│  │     close callbacks        │ ← socket.on('close')
│  └───────────┘───────────────┘
jsjavascript
// In Node.js, setImmediate fires after I/O, setTimeout fires in timers phase
const fs = require("fs");
 
fs.readFile(__filename, () => {
  // Inside an I/O callback:
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
  // Output: immediate, timeout (always in this order inside I/O)
});

process.nextTick is special — it runs before any other microtask, at the end of the current operation. Overusing it can starve I/O.

Common Pitfalls

Unintentional blocking

tstypescript
// ❌ Synchronous file read blocks the event loop
import { readFileSync } from "fs";
 
app.get("/data", (req, res) => {
  const data = readFileSync("/large-file.json", "utf-8");
  res.json(JSON.parse(data));
});
 
// ✅ Async read lets the event loop handle other requests
import { readFile } from "fs/promises";
 
app.get("/data", async (req, res) => {
  const data = await readFile("/large-file.json", "utf-8");
  res.json(JSON.parse(data));
});

Promise chains that grow unbounded

tstypescript
// ❌ Creates a microtask chain that delays event processing
async function pollForever() {
  while (true) {
    await checkForUpdates();
    // No yielding to macro tasks — this can delay timers
  }
}
 
// ✅ Yield control between iterations
async function pollForever() {
  while (true) {
    await checkForUpdates();
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
}

Key Takeaways

  1. Microtasks always run before the next macrotask — Promises fire before setTimeout(fn, 0)
  2. Microtask queues drain completely before yielding, which can block rendering
  3. Use setTimeout to yield to the browser's render pipeline for long-running work
  4. Node.js has additional event loop phases — setImmediate and process.nextTick have specific ordering guarantees
  5. Never block the event loop with synchronous I/O or CPU-heavy computation on the main thread
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX