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