DEV Community

Alex Chen
Alex Chen

Posted on

The JavaScript Event Loop Explained Simply (2026)

The JavaScript Event Loop Explained Simply (2026)

Understanding the event loop is the difference between "Node.js works" and "I know why Node.js works."

The Big Picture

JavaScript is single-threaded.
But it handles thousands of concurrent connections.

How? The Event Loop.

Think of it like a restaurant with one chef:
→ One order at a time (single thread)
→ But while food cooks, take the next order (non-blocking I/O)
→ Never idle — always something to do
Enter fullscreen mode Exit fullscreen mode

The Call Stack

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(x) {
  const result = square(x);
  console.log(result);
}

printSquare(4); // What happens?
Enter fullscreen mode Exit fullscreen mode
Step-by-step execution:

┌─────────────┐
│ printSquare │  ← push
│   square    │  ← push (calls square)
│   multiply  │  ← push (calls multiply)
│   return 16 │  ← pop (multiply returns)
│   return 16 │  ← pop (square returns)
│    log: 16  │  ← pop (console.log executes)
└─────────────┘

Stack empties out → program ends
Enter fullscreen mode Exit fullscreen mode

This is synchronous. Simple. Boring. Let's make it interesting:

Callbacks Enter The Chat

console.log('1');

setTimeout(() => {
  console.log('2');
}, 1000);

console.log('3');

// Output: 1 → 3 → 2 (NOT 1 → 2 → 3!)
Enter fullscreen mode Exit fullscreen mode

Why? Because setTimeout is asynchronous — it doesn't block the call stack.

The Full Event Loop Model

┌───────────────────────────┐
│                           │
│   ┌───────────────────┐  │
│   │   Call Stack       │  │  ← Your code runs here (LIFO)
│   │  - printSquare()   │  │
│   │  - square()        │  │
│   │  - multiply()      │  │
│   └─────────┬─────────┘  │
│             │             │
│             ▼             │
│   ┌───────────────────┐  │
│   │ Web APIs          │  │  ← Browser/Node runtime handles these:
│   │ - setTimeout      │  │     (DOM events, fetch, timers, etc.)
│   │ - fetch           │  │
│   │ - DOM events      │  │
│   └─────────┬─────────┘  │
│             │             │
│             ▼ (callback ready)
│   ┌───────────────────┐  │
│   │ Callback Queue    │  │  ← Waiting their turn
│   │ (Task Queue)      │  │
│   └─────────┬─────────┘  │
│             │             │
│             ▼             │
│   ┌───────────────────┐  │
│   │   EVENT LOOP      │  │  ← The conductor!
│   │ "Is stack empty?"  │  │  If yes → dequeue callback → push to stack
│   └───────────────────┘  │
│                           │
└───────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Visual Walkthrough

console.log('Start');

setTimeout(() => {
  console.log('Timeout 1 (0ms)');
}, 0);

setTimeout(() => {
  console.log('Timeout 2 (100ms)');
}, 100);

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

console.log('End');
Enter fullscreen mode Exit fullscreen mode

Output: StartEndMicrotaskTimeout 1 (0ms)Timeout 2 (100ms)

Why does Microtask run before Timeout?

There are actually two queues:

Microtask Queue (higher priority):
  - Promise.then/catch/finally
  - queueMicrotask()
  - MutationObserver

Macrotask Queue (lower priority):
  - setTimeout / setInterval
  - I/O callbacks
  - UI rendering (browser)
Enter fullscreen mode Exit fullscreen mode

Event Loop Priority:

1. Run everything in call stack until empty
2. Run ALL microtasks (drain entire microtask queue)
3. Run ONE macrotask
4. Go back to step 2 (check for new microtasks)

This means: Microtasks can starve macrotasks!
Enter fullscreen mode Exit fullscreen mode

Practical Example: Why This Matters

Example 1: Non-Blocking I/O

const fs = require('fs');

console.log('Reading file...');

fs.readFile('/etc/hostname', 'utf8', (err, data) => {
  // This callback goes to the queue
  // Event loop picks it up when I/O completes
  console.log('File contents:', data);
});

console.log('This logs BEFORE file read!');

// Output:
// Reading file...
// This logs BEFORE file read!
// File contents: <hostname>
Enter fullscreen mode Exit fullscreen mode

The readFile offloads work to the OS (thread pool). Your JS keeps running. When data arrives, callback enters the queue.

Example 2: The Starvation Problem

let counter = 0;

// This creates an infinite loop of microtasks
function scheduleMicrotask() {
  Promise.resolve().then(() => {
    counter++;
    if (counter < 10_000) {
      scheduleMicrotask();
    }
  });
}

scheduleMicrotask();

setTimeout(() => {
  console.log('Timer fired! Counter:', counter);
}, 0);

// When does "Timer fired!" log?
// Answer: NEVER (or after all 10,000 microtasks complete)
// The timer callback waits in the macrotask queue forever
Enter fullscreen mode Exit fullscreen mode

Example 3: setImmediate vs setTimeout(fn, 0)

// Node.js specific
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

// In Node.js: setImmediate fires FIRST (usually)
// Why? setImmediate checks the check queue (poll phase)
// setTimeout(0) uses the timer queue (timer phase)
// Timer phase comes before poll phase... but 0ms might round up
Enter fullscreen mode Exit fullscreen mode

async/await Is Just Syntactic Sugar

// These are IDENTICAL from event loop perspective:

async function getData() {
  const res = await fetch(url);  // await = yield + resume
  return res.json();
}

// Compiles to roughly:
function getData() {
  return fetch(url)
    .then(res => res.json());
}

// Each await pauses function execution
// Puts the rest into a microtask (.then callback)
// Continues when promise resolves
Enter fullscreen mode Exit fullscreen mode

Common Misconceptions

❌ "setTimeout(fn, 0) runs immediately"

No. It runs as soon as the call stack is empty AND the current turn of the event loop finishes. Minimum delay is typically ~1ms (or more under load).

❌ "setTimeout guarantees timing"

No. It guarantees MINIMUM delay. If the stack is busy with heavy computation, your callback waits.

const start = Date.now();

setTimeout(() => {
  console.log(`Actual delay: ${Date.now() - start}ms`);
}, 100);

// Block for 200ms
const end = Date.now() + 200;
while (Date.now() < end) {}

// Output: Actual delay: ~201ms (not 100!)
Enter fullscreen mode Exit fullscreen mode

❌ "Web Workers add threads to JavaScript"

Workers have their OWN event loop, own stack, own memory. They don't share memory with main thread. Communication via message passing. True parallelism, but no shared state.

Performance Implications

// ❌ Blocking the event loop
app.get('/heavy', (req, res) => {
  const result = crypto.pbkdf2Sync(password, salt, 100000, 512, 'sha256'); // Takes 500ms
  res.json({ result });
});
// During this 500ms, NO other request can be processed!

// ✅ Offload or break up
app.get('/heavy', async (req, res) => {
  const result = await new Promise((resolve) => {
    // Use worker_threads or break into chunks
    pbkdf2(password, salt, 100000, 512, 'sha256', (err, key) => {
      resolve(key.toString('hex'));
    });
  });
  res.json({ result });
});

// Or even simpler — use built-in async version if available
import { pbkdf2 } from 'node:crypto';
const result = await pbkdf2(password, salt, 100000, 512, 'sha256');
// Doesn't block because it uses thread pool internally
Enter fullscreen mode Exit fullscreen mode

Debugging Event Loop Issues

// Latency measurement
function measureLatency(label) {
  const start = process.hrtime.bigint();

  return {
    done: () => {
      const ns = Number(process.hrtime.bigint() - start);
      const ms = ns / 1_000_000;
      console.log(`${label}: ${ms.toFixed(2)}ms`);
      return ms;
    }
  }

// Usage
const m = measureLatency('API Call');
await fetchData();
m.done(); // API Call: 45.23ms
Enter fullscreen mode Exit fullscreen mode
// Detect event loop blocking
let lastTime = Date.now();

setInterval(() => {
  const now = Date.now();
  const delta = now - lastTime;
  lastTime = now;

  if (delta > 50) { // More than 50ms between ticks = blocked
    console.warn(`⚠️ Event loop blocked for ${delta}ms!`);
  }
}, 10); // Check every 10ms
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Mechanism Queue Type Typical Use
Promise.then Microtask Async flow control
queueMicrotask() Microtask Explicit microtask scheduling
process.nextTick() Microtask (Node) After current operation
setTimeout(fn, 0) Macrotask "After current code"
setImmediate() Macrotask (Node) "After I/O phase"
setInterval() Macrotask Repeating tasks
requestAnimationFrame() Macrotask (Browser) Before next paint

Does the event loop finally click for you?

Follow @armorbreak for more deep-dive JS guides.

Top comments (0)