Skip to main content
JavaScript intermediate Lesson 15 of 24

JavaScript Event Loop Explained

Understand how JavaScript's single-threaded event loop works, why microtasks run before macrotasks, and how to reason about async execution order.

JavaScript runs on a single thread — there is no parallel execution of your code. Yet it handles HTTP requests, timers, and user events without blocking. The event loop is the mechanism that makes this possible. Understanding it is not just academic — it explains why async code runs in the order it does, why the UI can freeze, and how to design code that stays responsive.

The Mental Model

Think of the runtime as four interacting components. Each plays a specific role:

  • Call Stack — a LIFO stack that tracks which function is currently executing. Synchronous code runs here, frame by frame.
  • Heap — unstructured memory where objects are allocated.
  • Web APIs (browser) / libuv (Node.js) — handles timers, network I/O, and DOM events outside the JS thread. These run in parallel with your code.
  • Task Queues — two queues: the microtask queue (high priority) and the callback queue / macrotask queue (lower priority).

The event loop rule is simple: when the call stack is empty, drain all microtasks, then pick one macrotask, then repeat.

A Concrete Execution Trace

Walking through a concrete example is the fastest way to internalize the ordering rules. Read the output prediction first, then verify it against the explanation:

console.log("1 — synchronous start");

setTimeout(() => console.log("2 — macrotask (setTimeout)"), 0);

Promise.resolve()
  .then(() => console.log("3 — microtask (Promise .then)"))
  .then(() => console.log("4 — microtask (chained .then)"));

queueMicrotask(() => console.log("5 — microtask (queueMicrotask)"));

console.log("6 — synchronous end");

Output:

1 — synchronous start
6 — synchronous end
3 — microtask (Promise .then)
5 — microtask (queueMicrotask)
4 — microtask (chained .then)
2 — macrotask (setTimeout)

Step-by-step:

  1. "1" and "6" run synchronously — call stack executes top to bottom.
  2. setTimeout registers a macrotask; Promise.resolve().then(...) queues a microtask; queueMicrotask queues a microtask.
  3. Call stack is now empty. Event loop drains the microtask queue: "3", then "5", then "4" (the chained .then was enqueued when "3" resolved).
  4. Microtask queue is empty. Event loop picks the next macrotask: "2".

Microtasks Run to Completion

All microtasks queued during microtask processing are also drained before any macrotask runs. This is by design — it ensures Promise chains resolve completely before the browser gets a chance to repaint or run timers. The downside is that a loop of recursive microtasks can starve the macrotask queue entirely:

// This will never let any macrotask run — the microtask queue never empties
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks); // enqueues a new microtask each time
}
// Don't do this — it freezes the browser/Node process permanently

The Call Stack in Practice

Synchronous code fills the call stack frame by frame. Each function call pushes a frame; each return pops it. When the stack is empty, the event loop can process queued callbacks.

function add(a, b) {
  return a + b;        // frame 3 — innermost, runs first to complete
}

function calculate(x) {
  return add(x, 10);   // frame 2
}

function main() {
  const result = calculate(5); // frame 1
  console.log(result); // 15
}

main();
// Stack lifecycle:
// [main] → [main, calculate] → [main, calculate, add]
// → [main, calculate] → [main] → []

A deep recursive call with no base case overflows the stack:

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

setTimeout(fn, 0) is Not Instantaneous

setTimeout(fn, 0) is a common pattern, but many developers assume it runs “right away.” In reality, it schedules a macrotask — meaning it will run after the current synchronous code finishes AND after all pending microtasks are drained.

function render() {
  console.log("render");
}

setTimeout(render, 0); // queued as a macrotask

// This Promise callback runs BEFORE render, even though setTimeout was called first
Promise.resolve().then(() => console.log("promise"));

// Output:
// promise   ← microtask runs first
// render    ← macrotask runs after all microtasks

Use setTimeout(fn, 0) to yield to the browser’s rendering pipeline (e.g., let a repaint happen before heavy work). Use queueMicrotask() when you need to defer to the end of the current task but before any I/O or rendering.

Practical: Avoiding UI Jank with Chunked Work

Long synchronous tasks block the call stack and freeze the UI — the browser cannot repaint or respond to input while JavaScript is running. The fix is to break heavy work into smaller chunks and yield between them, giving the browser time to paint and stay interactive.

function processInChunks(items, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);

    for (; index < end; index++) {
      // process one chunk synchronously
      heavyComputation(items[index]);
    }

    if (index < items.length) {
      // yield to the event loop — browser can repaint before next chunk
      setTimeout(processChunk, 0);
    }
  }

  processChunk();
}

For modern code, prefer requestIdleCallback (browser) for low-priority background work, or scheduler.postTask when available.

async/await and the Event Loop

async/await is syntax sugar over Promises. An await expression suspends the async function and enqueues the rest of the function as a microtask once the awaited Promise settles. This means code after await does not run synchronously — it runs as a microtask after the current call stack empties.

async function fetchUser(id) {
  console.log("A — before await");
  const user = await getUser(id); // function suspends here; rest is enqueued as microtask
  console.log("B — after await"); // runs as a microtask when getUser resolves
  return user;
}

console.log("1 — before call");
fetchUser(1);
console.log("2 — after call (synchronous)"); // runs before "B" because stack isn't empty yet

// Output:
// 1 — before call
// A — before await
// 2 — after call (synchronous)
// B — after await   ← microtask runs after call stack clears

Node.js Additions

Node.js adds two more scheduling mechanisms that sit between the standard queues:

  • process.nextTick() — runs before any other microtasks (even Promises). Use sparingly; it can delay I/O callbacks.
  • setImmediate() — runs after I/O callbacks in the current event loop iteration, before any setTimeout with a 0ms delay.

Priority order in Node.js: process.nextTick → microtasks (Promises) → I/O → setImmediatesetTimeout.

Key Takeaways

  • The call stack runs synchronous code; Web APIs handle async work off-thread.
  • When the stack empties, drain all microtasks, then run one macrotask, repeat.
  • Promises and queueMicrotask enqueue microtasks (higher priority than setTimeout).
  • setTimeout(fn, 0) guarantees only that the callback runs after the current microtask queue is empty.
  • Never block the call stack with long synchronous loops — break work into chunks.

Frequently Asked Questions

Why is JavaScript single-threaded if it can do async work?
JavaScript itself executes on a single thread, but the runtime (browser or Node.js) offloads I/O, timers, and network requests to Web APIs running in separate threads. The event loop coordinates returning their results to the JS thread when it is idle.
What is the difference between macrotasks and microtasks?
Macrotasks (setTimeout, setInterval, I/O callbacks) are queued in the callback queue and processed one at a time. Microtasks (Promise callbacks, queueMicrotask, MutationObserver) are queued in the microtask queue and all processed before the next macrotask runs.
Does setTimeout(fn, 0) run the callback immediately?
No. setTimeout(fn, 0) schedules a macrotask, so the callback runs after the current call stack is empty and after all pending microtasks have been flushed. The 0ms delay is a minimum, not a guarantee.