JavaScript runs on a single thread — one call stack, one piece of code executing at a time. The event loop is the mechanism that lets asynchronous operations (timers, network requests, user events) run without blocking the main thread indefinitely.

The Runtime Components

  ┌───────────────────────────┐
│        Call Stack         │  ← synchronous function execution
└─────────────┬─────────────┘
              │
┌─────────────▼─────────────┐
│   Web/Node APIs (libuv)   │  ← setTimeout, fetch, fs.readFile
└─────────────┬─────────────┘
              │ callbacks ready
┌─────────────▼─────────────┐
│   Microtask Queue         │  ← Promises, queueMicrotask (HIGH priority)
└─────────────┬─────────────┘
              │
┌─────────────▼─────────────┐
│   Macrotask Queue         │  ← setTimeout, setInterval, I/O, UI events
└───────────────────────────┘
  

The Call Stack

Functions are pushed onto the stack when called and popped when they return:

  function c() { console.log('c'); }
function b() { c(); }
function a() { b(); }

a();
// Stack progression: a → b → c → pop c → pop b → pop a
  

Stack overflow occurs with infinite recursion:

  function recurse() { recurse(); }
recurse(); // RangeError: Maximum call stack size exceeded
  

Web APIs and Node.js libuv

Operations like setTimeout, fetch, DOM events, and file I/O are handled outside the JavaScript engine. When they complete, their callbacks are placed in a queue — not executed immediately.

  console.log('start');
setTimeout(() => console.log('timeout'), 0);
console.log('end');
// start → end → timeout (not start → timeout → end)
  

Even setTimeout(fn, 0) waits until the stack is empty and microtasks are processed.

Microtasks vs Macrotasks

Type Sources Priority
Microtasks Promise.then/catch/finally, queueMicrotask, MutationObserver Run after current stack, before next macrotask
Macrotasks setTimeout, setInterval, I/O callbacks, UI events One per event loop iteration

Event Loop Algorithm

  1. Execute all synchronous code until the call stack is empty.
  2. Drain the entire microtask queue (all microtasks, including microtasks scheduled by other microtasks).
  3. Render the page (browser — if needed).
  4. Execute one macrotask.
  5. Repeat from step 2.

Classic Example

  console.log('1');

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

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

console.log('4');

// Output: 1, 4, 3, 2
  

Why:

  • 1 and 4 — synchronous, run immediately
  • 3 — Promise callback is a microtask, runs before macrotasks
  • 2 — setTimeout callback is a macrotask, runs last

Nested Promises and Timers

  console.log('start');

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

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

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

console.log('end');

// start → end → promise 1 → promise 2 → timeout 1 → timeout 2
  

Each macrotask iteration processes all pending microtasks first.

async/await and the Event Loop

await pauses an async function and schedules the continuation as a microtask:

  async function example() {
    console.log('A');
    await Promise.resolve();
    console.log('B'); // microtask — runs after synchronous code
}

example();
console.log('C');

// A → C → B
  

Multiple awaits create multiple microtask checkpoints:

  async function steps() {
    console.log(1);
    await null;
    console.log(2);
    await null;
    console.log(3);
}
steps();
console.log(4);
// 1 → 4 → 2 → 3
  

Blocking the Event Loop

Long synchronous work blocks everything — no rendering, no callbacks, frozen UI:

  // BAD: blocks for 5 seconds
function block() {
    const start = Date.now();
    while (Date.now() - start < 5000) {}
}
block();
  

Solutions:

  // Break work into chunks with setTimeout
function processChunk(items, index = 0) {
    const CHUNK = 100;
    const end = Math.min(index + CHUNK, items.length);
    for (let i = index; i < end; i++) processItem(items[i]);
    if (end < items.length) {
        setTimeout(() => processChunk(items, end), 0);
    }
}

// Or offload to Web Workers for CPU-heavy tasks
  

queueMicrotask

Schedule a microtask explicitly:

  console.log('sync');
queueMicrotask(() => console.log('microtask'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync 2');
// sync → sync 2 → microtask → promise (microtasks in order)
  

Node.js Event Loop Phases

Node.js has additional phases (timers, I/O poll, check, close callbacks). The microtask/macrotask distinction still applies — process.nextTick runs even before microtasks in Node.

  setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// nextTick → promise → timeout
  

Best Practices

  • Keep synchronous work on the main thread short (< 50ms for smooth UI).
  • Prefer async I/O (fetch, fs.promises) over blocking calls.
  • Use Web Workers for CPU-intensive computation.
  • Understand that setTimeout(0) is not immediate — it queues a macrotask.
  • Batch DOM updates to avoid layout thrashing within a single frame.

Troubleshooting

Symptom Likely Cause Fix
Promise runs before setTimeout Microtask priority Expected behavior — design accordingly
UI freezes during computation Long sync loop on main thread Chunk work or use Workers
Callback order surprises Mixed micro/macrotasks Trace execution with numbered logs
Memory leak with timers setInterval never cleared Store ID and call clearInterval
Race condition Assumed immediate async execution Use await or Promise chains explicitly

Understanding the event loop explains why JavaScript stays responsive despite being single-threaded — and why blocking the main thread is the cardinal sin of front-end performance.