Technology · JavaScript

Advanced

JavaScript Event Loop

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

TL;DR
  1. 01The call stack runs synchronous code one frame at a time.
  2. 02Microtasks always run before the next macrotask executes.
  3. 03Promise callbacks resolve before setTimeout, even with zero delay.

The Call Stack

  • The call stack tracks function calls in progress, adding a frame each time a function runs.
    function a() { b(); }
    function b() { console.log("in b"); }
    a(); // pushes a, then b, then pops each as they return
    
  • JavaScript runs on a single thread, so only one stack frame executes at any given moment.
    // No two functions run literally at the same time on the main thread
    
  • Synchronous code runs to completion before the engine looks at any queued callback.
    console.log("1");
    console.log("2");
    console.log("3"); // always logs in this order, no interleaving
    
  • A stack overflow happens when recursive calls keep pushing frames without returning.
    function recurse() { return recurse(); }
    // recurse(); // throws RangeError: Maximum call stack size exceeded
    
  • The event loop only starts processing queues once the call stack is completely empty.
    setTimeout(() => console.log("timer"), 0);
    console.log("sync"); // logs "sync" first, "timer" only after stack clears
    

Macrotasks and the Task Queue

  • Macrotasks include callbacks from setTimeout, setInterval, DOM events, and I/O completion.
    setTimeout(() => console.log("macrotask"), 0);
    console.log("sync code runs first");
    
  • The event loop runs one macrotask per iteration, then checks the microtask queue before the next one.
    setTimeout(() => console.log("task 1"), 0);
    setTimeout(() => console.log("task 2"), 0);
    // task 1 and task 2 each get their own full event loop turn
    
  • setTimeout(fn, 0) schedules a macrotask with an effective minimum delay, not true zero delay.
    setTimeout(() => console.log("later"), 0);
    console.log("now"); // logs "now" before "later" every time
    
  • Each macrotask can trigger new rendering, since the browser may paint between task queue turns.
    // The browser can repaint the screen after a macrotask finishes
    
  • Nested timers below a certain depth get clamped to a minimum delay, often around 4ms in browsers.
    function tick() {
      setTimeout(tick, 0); // browser clamps repeated nested timers
    }
    

Microtasks and Promises

  • Promise .then(), .catch(), and .finally() callbacks all run as microtasks.
    Promise.resolve().then(() => console.log("microtask"));
    console.log("sync"); // logs "sync", then "microtask"
    
  • Use queueMicrotask() to schedule a callback directly on the microtask queue without a Promise.
    queueMicrotask(() => console.log("runs before next render or timer"));
    
  • The microtask queue fully drains before the event loop moves to the next macrotask.
    Promise.resolve().then(() => console.log("micro 1"));
    Promise.resolve().then(() => console.log("micro 2"));
    setTimeout(() => console.log("macro"), 0);
    // logs: micro 1, micro 2, macro
    
  • A microtask that schedules another microtask still runs before the next macrotask.
    Promise.resolve().then(() => {
      Promise.resolve().then(() => console.log("nested microtask"));
    });
    setTimeout(() => console.log("timer"), 0);
    // "nested microtask" logs before "timer"
    
  • await inside an async function suspends execution and resumes as a microtask once the awaited value settles.
    async function run() {
      console.log("start");
      await null;
      console.log("resumed as microtask");
    }
    run();
    console.log("after call");
    // logs: start, after call, resumed as microtask
    

setTimeout vs Promise Ordering

  • Promise callbacks always run before a setTimeout callback scheduled in the same synchronous block.
    setTimeout(() => console.log("timeout"), 0);
    Promise.resolve().then(() => console.log("promise"));
    // logs: promise, timeout
    
  • This holds true even if the setTimeout delay is shorter than the time the microtask queue takes to drain.
    setTimeout(() => console.log("timeout"), 0);
    for (let i = 0; i < 3; i++) {
      Promise.resolve().then(() => console.log(`micro ${i}`));
    }
    // all three micro logs print before "timeout"
    
  • Mixing timers and promises in a loop reveals the ordering clearly.
    console.log("1");
    setTimeout(() => console.log("2"), 0);
    Promise.resolve().then(() => console.log("3"));
    console.log("4");
    // logs: 1, 4, 3, 2
    
  • requestAnimationFrame runs before the next repaint, separate from both the microtask and macrotask queues.
    requestAnimationFrame(() => console.log("before paint"));
    setTimeout(() => console.log("macrotask"), 0);
    
  • Async/await ordering follows the same microtask rules as explicit .then() chains.
    async function asyncFn() { console.log("async"); }
    asyncFn();
    setTimeout(() => console.log("timeout"), 0);
    Promise.resolve().then(() => console.log("promise"));
    // logs: async, promise, timeout
    

Why the UI Doesn't Block

  • The browser interleaves rendering with macrotasks, repainting the screen between queue turns.
    // Each setTimeout callback gives the browser a chance to render afterward
    
  • Breaking a long loop into smaller setTimeout-scheduled chunks lets the UI stay responsive.
    function processChunk(items, index = 0) {
      const end = Math.min(index + 100, items.length);
      for (let i = index; i < end; i++) { /* work */ }
      if (end < items.length) {
        setTimeout(() => processChunk(items, end), 0);
      }
    }
    
  • requestIdleCallback schedules work during browser idle periods, avoiding interference with rendering.
    requestIdleCallback(() => {
      // low-priority work that can wait for idle time
    });
    
  • Web Workers run JavaScript on a separate thread, keeping heavy computation off the main thread entirely.
    const worker = new Worker("heavy-task.js");
    worker.postMessage(data);
    worker.onmessage = (e) => console.log(e.data);
    
  • A single long synchronous function still blocks everything, since async APIs only help between tasks, not during one.
    function blockEverything() {
      const start = Date.now();
      while (Date.now() - start < 5000) {} // freezes the page for 5 seconds
    }
    
Tips
  1. 01Use queueMicrotask() when you need code to run after the current synchronous block but before any rendering or timers.
  2. 02Break long synchronous loops into chunks with setTimeout so the browser gets a chance to render between batches.
  3. 03Reach for Promise chains over deeply nested setTimeout calls, since microtasks resolve predictably before the next macrotask.
Warnings
  1. 01A long-running synchronous function blocks the call stack entirely, freezing rendering and user input until it finishes.
  2. 02Chaining many Promise.then() calls can starve macrotasks, since the microtask queue must fully drain before the next one runs.
  3. 03setTimeout(fn, 0) does not run immediately — it waits for the call stack and queued microtasks to finish first.
FAQ
JavaScript Currying and CompositionJavaScript Generators