RoadmapDay 3 / 80
JavaScript CoreMonth 1 · Week 1

Day 3: The Event Loop, Callbacks, Microtasks vs Macrotasks

Trace exactly why Promise callbacks run before setTimeout callbacks — by understanding the queues the event loop drains and in what order.

Mark this day complete

Study

Concepts

JS is single-threaded; the runtime is not

The JS engine itself runs one thing at a time on one Call Stack. Asynchronous behavior (timers, network I/O, DOM events) is handled by the surrounding runtime — the browser or Node — which offloads the work and, once it completes, hands a callback to a queue for the engine to pick up later.

The Event Loop is the mechanism that repeatedly checks: "Is the Call Stack empty? If yes, take the next task from a queue and push it."

Two queues, not one

Macrotasks (a.k.a. the "task queue"): setTimeout, setInterval, DOM events, I/O, postMessage. Only ONE macrotask is processed per event loop tick.

Microtasks: Promise .then/.catch/.finally callbacks, queueMicrotask, and (in Node) process.nextTick (which runs even earlier than other microtasks). After every single macrotask — and after the initial synchronous script — the engine fully drains the ENTIRE microtask queue, including any new microtasks scheduled during that drain, before it renders a frame or picks up the next macrotask.

Why this ordering matters in real UIs

Because microtasks drain completely before the next macrotask, a chain of `.then()` calls that keeps queueing more `.then()` calls can starve the event loop and delay rendering or timers indefinitely — a real production bug pattern.

In the browser, rendering (style/layout/paint) is scheduled as its own step between microtask-draining and the next macrotask, which is why microtask-heavy code can make the UI feel frozen even though "nothing is blocking" in the traditional sense.

See It

Visualizations

Visualization

One tick of the Event Loop

Order: run sync code → drain ALL microtasks → render (browser) → run ONE macrotask → repeat.

Call Stack

currently executing frame

Microtask Queue (drained fully, every tick)

Promise.then #1

queueMicrotask()

Promise.then #2 (queued during drain)

Macrotask Queue (one per tick)

setTimeout callback

click event handler

Build It

Code Examples

Predict the log order before running it

js
console.log('1: sync start');

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

Promise.resolve()
  .then(() => console.log('3: microtask A'))
  .then(() => console.log('4: microtask A.1 (queued during drain)'));

queueMicrotask(() => console.log('5: microtask B'));

console.log('6: sync end');

// Actual order: 1, 6, 3, 5, 4, 2
// Sync code always finishes first, THEN microtasks fully drain
// (including ones queued mid-drain), THEN the macrotask runs.

A microtask trap that starves setTimeout

js
let count = 0;
function recurse() {
  count++;
  if (count < 5) {
    // Each .then schedules ANOTHER microtask before the loop can
    // yield to the macrotask queue — the setTimeout below waits.
    Promise.resolve().then(recurse);
  }
}
recurse();
setTimeout(() => console.log('finally runs after all microtasks drain'), 0);

Remember

Key Takeaways

  • Sync code always runs to completion first — the event loop never interrupts a running frame.
  • After sync code (and after each macrotask), ALL pending microtasks drain, including new ones queued mid-drain.
  • Only one macrotask is processed per loop tick.
  • Promise callbacks (microtasks) always beat setTimeout callbacks (macrotasks), even setTimeout(fn, 0).
  • Runaway microtask chains can starve rendering and timers — a real perf bug, not just trivia.

Do It

Practice

  1. 1Write a snippet mixing 2 setTimeouts, 2 Promise.then chains, and a queueMicrotask; predict the order on paper, then verify.
  2. 2Reproduce the microtask-starvation example and add a counter to prove the setTimeout only fires after the chain fully resolves.
  3. 3Open the Node.js docs on process.nextTick and explain in one paragraph why it runs before other microtasks.