DEV Community

Alex Chen
Alex Chen

Posted on

The JavaScript Event Loop Explained Simply

The JavaScript Event Loop Explained Simply

The event loop is the reason JavaScript can do async work with a single thread. Here's how it actually works.

The Big Picture

┌─────────────────────────────────┐
│         JavaScript Code          │
│   (runs on single thread)        │
└─────────┬───────────────────────┘
          │
┌─────────▼───────────────────────┐
│         Event Loop               │
│                                   │
│  1. Check Call Stack             │
│  2. If empty → pick from Queue   │
│  3. Execute callback             │
│  4. Repeat                       │
└─────────────────────────────────┘

Key insight: JavaScript NEVER runs two things at once.
It just switches REALLY FAST between tasks.
Enter fullscreen mode Exit fullscreen mode

The Three Parts

// Part 1: Call Stack (what's executing NOW)
function first() {
  console.log('first');
  second();
  console.log('first done');
}

function second() {
  console.log('second');
  third();
  console.log('second done');
}

function third() {
  console.log('third');
}

first();
// Output: first → second → third → second done → first done
// Stack grows down, unwinds back up

// Part 2: Web APIs (browser/Node background work)
// setTimeout, fetch, DOM events, file I/O
// These run OUTSIDE the JavaScript thread

// Part 3: Task Queue (callbacks waiting to run)
// When a Web API finishes → its callback goes in the queue
// Event loop picks from queue ONLY when call stack is empty
Enter fullscreen mode Exit fullscreen mode

setTimeout(fn, 0) Trick

console.log('1');

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

console.log('3');

// Output: 1, 3, 2
// WHY? setTimeout puts callback in the queue, NOT on the stack
// The call stack must finish (print 3) before the queue is checked

// This is the core of async in JavaScript!
// Everything that's "async" is really just "deferred to the queue"
Enter fullscreen mode Exit fullscreen mode

Microtasks vs Macrotasks

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
// script end
// promise 1
// promise 2
// setTimeout

// WHY? Two queues, different priorities:
// 1. Microtask Queue (Promises, queueMicrotask, MutationObserver)
//    → Runs FIRST, empties COMPLETELY before next macrotask
// 2. Macrotask Queue (setTimeout, setInterval, I/O, UI events)
//    → Runs ONE task, then checks microtask queue again
Enter fullscreen mode Exit fullscreen mode

Visual: Event Loop Cycle

┌──────────────────┐
│   Call Stack     │ ← JavaScript executes here
└────────┬─────────┘
         │ (empty?)
         ▼
┌──────────────────┐
│ Microtask Queue  │ ← Promises (runs ALL before moving on)
└────────┬─────────┘
         │ (empty?)
         ▼
┌──────────────────┐
│  Render (DOM)    │ ← Browser paints if needed
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Macrotask Queue  │ ← setTimeout, I/O (runs ONE)
└────────┬─────────┘
         │
         └──→ Back to Call Stack
Enter fullscreen mode Exit fullscreen mode

Real-World Examples

Example 1: Blocking the Event Loop

// ❌ This blocks EVERYTHING for 3 seconds
function heavy() {
  const start = Date.now();
  while (Date.now() - start < 3000) {} // Busy wait!
  console.log('Done!');
}

document.getElementById('btn').addEventListener('click', () => {
  console.log('clicked!'); // Won't fire until heavy() finishes!
});

heavy(); // Blocks for 3 seconds
// Clicks during those 3 seconds are QUEUED, not ignored

// ✅ Use async instead
async function heavy() {
  // Break work into chunks
  for (let i = 0; i < 1000000; i++) {
    doWork();
    if (i % 10000 === 0) {
      await new Promise(r => setTimeout(r, 0)); // Yield to event loop
    }
  }
  console.log('Done!');
}
Enter fullscreen mode Exit fullscreen mode

Example 2: Why async/await Works

async function fetchUser() {
  console.log('1. Before fetch');

  const response = await fetch('/api/user');
  // ^ Execution SUSPENDS here
  // Function returns a Promise
  // Event loop continues processing other things
  // When fetch completes → callback goes in microtask queue
  // → Execution RESUMES here

  console.log('3. After fetch');
  const user = await response.json();
  console.log('5. After JSON parse');
  return user;
}

console.log('A');
fetchUser();
console.log('B');

// Output: A, 1. Before fetch, B, 3. After fetch, 5. After JSON parse
// The function "pauses" at await but the event loop keeps running!
Enter fullscreen mode Exit fullscreen mode

Example 3: Promise.all and the Event Loop

const p1 = fetch('/api/users').then(r => r.json());
const p2 = fetch('/api/posts').then(r => r.json());
const p3 = fetch('/api/comments').then(r => r.json());

// All three fetches start simultaneously (parallel I/O!)
// Each resolves independently → callbacks in microtask queue

const [users, posts, comments] = await Promise.all([p1, p2, p3]);
// Resolves when ALL three are done
// This is NOT parallel JavaScript execution!
// The I/O is parallel (handled by browser/Node)
// The processing of results is sequential (single thread)
Enter fullscreen mode Exit fullscreen mode

Node.js Specifics

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

// 1. Timers: setTimeout, setInterval callbacks
// 2. Pending callbacks: I/O callbacks deferred to next loop
// 3. Idle, prepare: Internal use
// 4. Poll: Retrieve new I/O events; execute I/O callbacks
// 5. Check: setImmediate callbacks
// 6. Close callbacks: close event callbacks

// Key difference: setImmediate vs setTimeout
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// Output is NON-DETERMINISTIC (could be either order!)
// In Node.js: depends on which phase the script finishes in

// To guarantee order:
setImmediate(() => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// Always: immediate, timeout
// (setImmediate callback runs → checks microtasks → 
//  checks timers queue → checks immediate queue)
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

// ❌ Mistake 1: Assuming setTimeout order
setTimeout(() => console.log('a'), 100);
setTimeout(() => console.log('b'), 100);
// a and b can come in ANY order (they're both "approximately" 100ms)

// ❌ Mistake 2: Forgetting await
async function getData() {
  const result = fetchData(); // Missing await!
  console.log(result); // Promise, not data!
}
// Fix: const result = await fetchData();

// ❌ Mistake 3: Blocking in a loop
for (const item of largeArray) {
  processSync(item); // Blocks entire event loop
}
// Fix: Use setImmediate or process.nextTick for large batches

// ❌ Mistake 4: Not understanding that Promise callbacks are microtasks
console.log('start');
setTimeout(() => console.log('timeout'), 0);
queueMicrotask(() => console.log('microtask'));
console.log('end');
// Output: start, end, microtask, timeout
Enter fullscreen mode Exit fullscreen mode

Quick Reference Card

Concept Queue Type Priority
Promise.then() Microtask High (runs all)
await Microtask High
queueMicrotask() Microtask High
MutationObserver Microtask High
setTimeout() Macrotask Low (runs one)
setInterval() Macrotask Low
setImmediate() Macrotask Low
I/O callbacks Macrotask Low
UI events Macrotask Low

Did this make the event loop click for you? Any questions?

Follow @armorbreak for more JavaScript content.

Top comments (0)