JavaScript Event Loop

Learn how the call stack, microtask queue, and task queue control JavaScript execution order.

TL;DR

  1. The call stack executes synchronous code one frame at a time.
  2. Microtasks execute immediately after the current stack frame completes.
  3. Promises resolve before macrotasks like setTimeout callbacks get processed.

The Call Stack and Macrotasks

    Call stack frames

    Tracks calls, stacking frames until each returns.

    function a(){b();} function b(){console.log("b");}
    a(); // a, then b, then pops
    Single execution thread

    Only one stack block runs at a time.

    // Synchronous execution runs sequentially
    Sync completeness

    Finishes sync code before checking event queues.

    console.log(1); console.log(2); // 1 before 2
    Stack overflow

    Unbounded recursion exhausts the stack.

    function recurse() { return recurse(); }
    // recurse(); // Maximum stack exceeded
    Macrotask callbacks

    Timer, network, and user action callbacks.

    setTimeout(() => console.log("macro"), 0);
    console.log("sync");
    Single task turns

    One macrotask runs per loop turn.

    setTimeout(() => console.log("1"), 0);
    setTimeout(() => console.log("2"), 0);
    Browser painting

    Paint updates happen between task queue turns.

    // Repaints occur after a macrotask completes

Microtasks and Promises

    Promise microtasks

    Runs resolve, reject, and finally callbacks inside the microtask queue.

    Promise.resolve().then(() => console.log("micro"));
    Direct queueing

    Schedules low-level microtasks directly using the queueMicrotask method.

    queueMicrotask(() => console.log("fast"));
    Full queue draining

    Forces all queued microtasks to run before the loop advances.

    Promise.resolve().then(() => console.log("m1"));
    Promise.resolve().then(() => console.log("m2"));
    // m1 and m2 execute back-to-back
    Nested microtasks

    Processes nested microtasks inside the same loop iteration drain phase.

    Promise.resolve().then(() => {
      Promise.resolve().then(() => console.log("nested"));
    });

setTimeout vs Promise Ordering

    Order priority

    Executes promise resolutions before timers scheduled within the same block.

    setTimeout(() => console.log("time"), 0);
    Promise.resolve().then(() => console.log("prom"));
    // logs: prom, time
    Loop interleaving

    Runs all pending microtasks before processing any scheduled timeout callbacks.

    setTimeout(() => console.log("time"), 0);
    [1, 2].forEach(n => {
      Promise.resolve().then(() => console.log(n));
    });
    // logs: 1, 2, time
    rAF visual timing

    Runs animation callbacks before paint updates, outside standard queues.

    requestAnimationFrame(() => console.log("paint"));

UI Responsiveness

    Execution chunking

    Schedules iteration batches with setTimeout to avoid locking layout repaints.

    function chunk(items, index = 0) {
      const end = Math.min(index + 100, items.length);
      for (let i = index; i < end; i++) { /* do work */ }
      if (end < items.length) {
        setTimeout(() => chunk(items, end), 0);
      }
    }
    Web Workers

    Delegates heavy computations to separate threads away from main render loops.

    const worker = new Worker("task.js");
    worker.postMessage(data);
    worker.onmessage = e => console.log(e.data);

Tips

  1. Use the native queueMicrotask() method to run scripts immediately after synchronous blocks but before paint updates.
  2. Split heavy data processing into smaller setTimeout batches to give browsers time to render frames.

Warnings

  1. Running a long synchronous loop blocks the single execution thread, freezing page interactions and rendering updates.
  2. Remember that recursive promise microtasks can starve macrotasks by preventing the event loop from advancing.

In Practice

FAQ