DEV Community

Alex Chen
Alex Chen

Posted on

The JavaScript Event Loop Explained Simply

The JavaScript Event Loop Explained Simply

Why does setTimeout sometimes take longer than expected? The event loop holds the answer.

The Big Picture

┌──────────────────────────────┐
│         Call Stack           │  ← Your code runs here (LIFO)
│   (function executions)      │
├──────────────────────────────┤
│     Web APIs (Browser)       │  ← setTimeout, fetch, DOM...
│    / C++ APIs (Node.js)      │
├──────────────────────────────┤
│        Task Queue            │  ← Callbacks wait here
│  (setTimeout, I/O, etc.)     │
├──────────────────────────────┤
│     Microtask Queue          │  ← Promises, queueMicrotask
│  (Promise.then/catch/finally)│
└──────────────────────────────┘

         ↕ Event Loop (moves tasks between queues)
Enter fullscreen mode Exit fullscreen mode

How It Works — Step by Step

console.log('1');

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

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

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

What gets logged?

1 → 4 → 3 → 2
Enter fullscreen mode Exit fullscreen mode

Why?

1. console.log('1') executes immediately on call stack → Output: "1"
2. setTimeout schedules callback in Task Queue (macrotask)
3. Promise.resolve().then() schedules callback in Microtask Queue
4. console.log('4') executes immediately → Output: "4"
5. Call stack is empty → Event Loop checks Microtask Queue first!
6. Promise callback runs → Output: "3"
7. Microtask Queue empty → Event Loop checks Task Queue
8. setTimeout callback runs → Output: "2"
Enter fullscreen mode Exit fullscreen mode

The Golden Rule

MICROTASKS ALWAYS RUN BEFORE MACROTASKS

Microtasks: Promise.then/catch/finally, queueMicrotask, MutationObserver
Macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
Enter fullscreen mode Exit fullscreen mode
// Proof:
console.log('Start');

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

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

// Output:
// Start
// Promise 1
// Promise 2
// Promise 3
// Timeout

// ALL microtasks run before the next macrotask!
// Even if you chain 100 .then() calls, they ALL run before setTimeout.
Enter fullscreen mode Exit fullscreen mode

Why setTimeout(fn, 0) Isn't Instant

const start = Date.now();

setTimeout(() => {
  console.log(`Timeout after ${Date.now() - start}ms`);
}, 0);

// Block the call stack for 100ms
let i = 0;
while (Date.now() - start < 100) {
  i++;
}

console.log(`Loop done after ${Date.now() - start}ms`);

// Output:
// Loop done after 100ms
// Timeout after ~102ms (not 0ms!)
Enter fullscreen mode Exit fullscreen mode

The minimum delay is never 0ms:

  • Browser: minimum 4ms (after 5th nested timer)
  • Node.js: minimum 1ms
  • Plus: whatever time the call stack is busy

Real-World Impact

Problem 1: Starving the UI

// ❌ Blocking the main thread
function heavyComputation() {
  const result = [];
  for (let i = 0; i < 10_000_000; i++) {
    result.push(Math.sqrt(i));
  }
  return result;
}

heavyComputation(); // Page freezes for seconds!

// ✅ Yield to the event loop periodically
async function nonBlockingComputation() {
  const result = [];
  for (let i = 0; i < 10_000_000; i++) {
    result.push(Math.sqrt(i));

    // Every 100K iterations, yield to let other code run
    if (i % 100_000 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Problem 2: Race Conditions

let data;

fetch('/api/data')
  .then(res => res.json())
  .then(json => { data = json; });

console.log(data); // undefined! fetch hasn't completed yet!

// ✅ Use async/await
async function getData() {
  const res = await fetch('/api/data');
  return await res.json();
}

const data = await getData(); // Waits for completion
console.log(data); // Works!
Enter fullscreen mode Exit fullscreen mode

Problem 3: Microtask Starvation

// DANGEROUS: Infinite microtask loop freezes everything!
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks);
}

infiniteMicrotasks();
// No rendering, no input handling, no timers — page completely frozen!
// Because microtasks run until EMPTY before anything else.
Enter fullscreen mode Exit fullscreen mode

requestAnimationFrame — For Visual Updates

// Runs before browser's next paint (usually 60fps)
function animate() {
  element.style.left = `${x}px`;
  x += 1;

  if (x < 500) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);

// Order of execution per frame:
// 1. Run requestAnimationFrame callbacks
// 2. Run IntersectionObserver callbacks
// 3. Render/paint the DOM
// 4. Check Task Queue for new macrotasks
Enter fullscreen mode Exit fullscreen mode

Node.js Specifics

// Node.js has additional phases in its event loop:

timers  pending callbacks  idle/prepare  poll  check  close callbacks

// setImmediate (Node.js only) — runs in "check" phase
// setTimeout — runs in "timers" phase
// setImmediate runs BEFORE setTimeout(0) in Node.js!

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

// In Node.js output: immediate → timeout
// In browser: timeout → immediate (no setImmediate)
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Type Examples Priority
Synchronous console.log, calculations Highest (runs now)
Microtask .then, queueMicrotask High (before render/macrotask)
requestAnimationFrame rAF callbacks Before paint
Macrotask setTimeout, setInterval, I/O Lowest (after microtasks)

Did this change how you think about async JS?

Follow @armorbreak for more JavaScript content.

Top comments (0)