Skip to content
JavaScript js advanced 4 min read

The Event Loop in Depth

JavaScript runs on a single thread, yet it juggles timers, network responses, and user input without blocking. The event loop is the coordinator that makes this possible: it decides when each piece of queued work runs and in what order. Understanding it removes the mystery from “why did this log print before that?” and lets you reason precisely about asynchronous code, avoid UI jank, and write predictable programs.

The mental model

At any moment the runtime holds a few distinct structures. The call stack tracks the function currently executing. When the stack empties, the event loop checks two queues. The microtask queue holds promise callbacks (.then/.catch/.finally), await continuations, and anything scheduled with queueMicrotask. The macrotask queue (also called the task queue) holds callbacks from setTimeout, setInterval, I/O, and DOM events.

The single most important rule: after every single macrotask, the engine drains the entire microtask queue before doing anything else — including rendering or picking up the next macrotask.

          ┌────────────────────────┐
          │      Call Stack        │  <- runs sync code to completion
          └────────────────────────┘
                     │ (stack empty)

   ┌─────────────────────────────────────┐
   │  Drain ALL microtasks (promises,    │  <- runs to exhaustion
   │  await continuations, queueMicrotask)│
   └─────────────────────────────────────┘


   ┌─────────────────────────────────────┐
   │  (Browser) Render: rAF, style, paint│
   └─────────────────────────────────────┘


   ┌─────────────────────────────────────┐
   │  Take ONE macrotask (setTimeout, I/O)│ ──┐
   └─────────────────────────────────────┘   │
                     ▲                         │
                     └─────── loop ────────────┘

A worked ordering example

This snippet mixes synchronous code, a timer, a resolved promise, and an explicit microtask. Predict the output before reading on.

console.log("1: sync start");

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

Promise.resolve().then(() => console.log("3: promise .then (microtask)"));

queueMicrotask(() => console.log("4: queueMicrotask (microtask)"));

console.log("5: sync end");

Output:

1: sync start
5: sync end
3: promise .then (microtask)
4: queueMicrotask (microtask)
2: setTimeout (macrotask)

The reasoning, step by step:

  1. Lines 1 and 5 are synchronous, so they run first, top to bottom, while the call stack is busy.
  2. setTimeout registers a macrotask; .then and queueMicrotask register microtasks.
  3. The stack empties. The loop drains all microtasks now: 3 then 4, in the order they were queued.
  4. Only after the microtask queue is empty does the loop pick up the single macrotask: 2.

A setTimeout(fn, 0) does not run “immediately.” It runs after the current task and the entire microtask queue have completed — often several milliseconds later.

Where await fits

await is syntactic sugar over promise continuations. Everything after an await is scheduled as a microtask once the awaited value settles.

async function run() {
  console.log("A");
  await null;            // suspends; resumes as a microtask
  console.log("C");
}

console.log("start");
run();
console.log("B");

Output:

start
A
B
C

A logs synchronously up to the await. Control returns to the caller (B logs), and only when the stack clears does the continuation (C) run as a microtask.

Microtasks vs macrotasks

SourceQueueDrainedTypical use
Promise.then / awaitMicrotaskFully, after each taskSequencing async results
queueMicrotaskMicrotaskFully, after each taskDefer work, keep promise ordering
setTimeout / setIntervalMacrotaskOne per loop turnTimers, yielding to the UI
DOM events, I/O callbacksMacrotaskOne per loop turnUser input, network
requestAnimationFrameRender phaseBefore paint (~60fps)Smooth animations

Starvation: when microtasks never end

Because the loop refuses to advance until the microtask queue is empty, a microtask that schedules another microtask can starve the loop forever. Rendering and timers will never get a turn.

// DANGER: this freezes the page — the microtask queue never empties
function flood() {
  queueMicrotask(flood);
}
flood();

To yield back to the event loop — allowing rendering and other tasks to run — schedule continuation work as a macrotask instead:

function chunk(items, i = 0) {
  const end = Math.min(i + 1000, items.length);
  for (; i < end; i++) process(items[i]);
  if (i < items.length) {
    setTimeout(() => chunk(items, i), 0); // yields to the loop
  }
}

Rendering and animation timing

In the browser, the loop interleaves a render step between macrotasks (roughly aligned to the display’s refresh rate). Callbacks registered with requestAnimationFrame run just before paint, after microtasks but before the browser draws the frame. This makes rAF the correct place to mutate the DOM for animation.

function animate() {
  el.style.transform = `translateX(${x++}px)`;
  requestAnimationFrame(animate); // re-schedules for the next frame
}
requestAnimationFrame(animate);

Avoid layout work inside setTimeout loops for animation: the timing drifts and you can paint multiple times per frame. requestAnimationFrame synchronizes precisely with the render step.

Node.js differences

Node uses libuv and adds phases the browser lacks. process.nextTick callbacks run before the promise microtask queue, and setImmediate fires in a dedicated check phase after I/O. The microtask-after-every-task rule still holds, but the macrotask ordering is phase-based rather than a single queue.

Best Practices

  • Reach for queueMicrotask when you need to defer work but preserve promise ordering; use setTimeout(fn, 0) when you specifically want to yield to rendering and other tasks.
  • Never assume setTimeout(fn, 0) is instant — treat it as “after the current task and all microtasks.”
  • Break long synchronous loops into chunks that yield via a macrotask to keep the UI responsive.
  • Avoid recursively scheduling microtasks; it starves rendering and timers.
  • Do DOM mutations for animation inside requestAnimationFrame, not in timers.
  • Remember await resumes as a microtask — code after it never runs in the same tick.
  • In Node, account for process.nextTick running before promise microtasks and setImmediate running in the check phase.
Last updated June 1, 2026
Was this helpful?