15 — Event Loop Internals: Call Stack, Microtasks, Macrotasks & Rendering
The Event Loop Model
javascript
// ── Single-threaded JS: one call stack, one event loop, one heap ──
//
// ┌──────────────────┐
// │ Call Stack │ ← LIFO — function frames (pushed/popped as functions call/return)
// │ [main()] │
// │ [outer()] │
// │ [inner()] ← top │ ← always executes the top frame
// └──────────────────┘
//
// ┌──────────────────┐ ┌───────────────────────┐
// │ Heap (memory) │ │ Web/Node APIs (C++) │
// │ objects, closures│ │ setTimeout, fetch, │
// │ (GC managed) │ │ DOM, I/O, crypto │
// └──────────────────┘ └───────────┬───────────┘
// │ (callback ready)
// ┌─────────────────────────────────────▼───────────────────┐
// │ Event Loop (one iteration = one "tick") │
// │ │
// │ 1. Pick ONE macrotask from the macrotask queue │
// │ → setTimeout, setInterval, I/O, UI events │
// │ │
// │ 2. Drain the microtask queue (ALL microtasks) │
// │ → Promise.then/catch/finally, queueMicrotask │
// │ → new microtasks added during step 2 also run NOW │
// │ │
// │ 3. Render (browser only): │
// │ → requestAnimationFrame callbacks │
// │ → style/layout calculation │
// │ → paint │
// │ │
// │ 4. Go back to step 1 (next tick) │
// └────────────────────────────────────────────────────────────┘
// ── Execution order demonstration ──
console.log("1: script start (synchronous)");
setTimeout(() => console.log("5: macrotask (setTimeout)"), 0);
Promise.resolve()
.then(() => console.log("3: microtask (Promise.then)"))
.then(() => console.log("4: microtask (chained .then)"));
queueMicrotask(() => console.log("2: microtask (queueMicrotask)"));
console.log("6: script end (synchronous)");
// Output order: 1, 6, 2, 3, 4, 5
// 1, 6: synchronous (script is a macrotask — runs first)
// 2, 3, 4: microtasks (drained after script, before next macrotask)
// 5: macrotask (setTimeout — runs after all microtasks drained)
Microtask Queue: Full Drain Semantics
javascript
// ── The microtask queue is FULLY drained before the next macrotask ──
// New microtasks added during the drain ALSO run in the same drain (no limit).
Promise.resolve().then(() => {
console.log("A");
Promise.resolve().then(() => console.log("B")); // added during drain — runs NOW
});
Promise.resolve().then(() => console.log("C"));
console.log("D");
// Output: D, A, C, B
// D: synchronous
// A: first microtask (queued before D finished)
// C: second microtask (queued at the same time as A)
// B: added during A's execution → runs in the SAME drain (after C, before next macrotask)
// ── Microtask starvation: infinite microtasks block macrotasks forever ──
// (Don't run this — it blocks the event loop)
// function infinite() {
// Promise.resolve().then(infinite);
// }
// infinite();
// setTimeout(() => console.log("never"), 0); // never runs — microtask queue never empties
// ── Node.js: process.nextTick vs Promise microtask ──
// process.nextTick has HIGHER priority than Promise microtasks:
// Node only:
// process.nextTick(() => console.log("nextTick"));
// Promise.resolve().then(() => console.log("promise"));
// console.log("sync");
// Output: sync, nextTick, promise (nextTick drains before Promise microtasks)
Callbacks and the Pyramid of Doom
javascript
// ── Callbacks: the original async pattern (pre-Promises) ──
// Node.js error-first callback convention:
const fs = require("fs");
fs.readFile("config.json", "utf8", (err, data) => {
if (err) {
console.error("error:", err);
return;
}
const config = JSON.parse(data);
// Nested callback — "pyramid of doom" / "callback hell":
fs.readFile(config.templatePath, "utf8", (err, template) => {
if (err) { console.error(err); return; }
const rendered = template.replace("{{name}}", config.name);
fs.writeFile(config.outputPath, rendered, (err) => {
if (err) { console.error(err); return; }
console.log("done");
});
});
});
// ── Fix: named functions (flatten the pyramid) ──
function loadConfig(cb) {
fs.readFile("config.json", "utf8", (err, data) => {
if (err) return cb(err);
cb(null, JSON.parse(data));
});
}
function loadTemplate(config, cb) {
fs.readFile(config.templatePath, "utf8", (err, template) => {
if (err) return cb(err);
cb(null, config, template);
});
}
function writeOutput(config, template, cb) {
const rendered = template.replace("{{name}}", config.name);
fs.writeFile(config.outputPath, rendered, cb);
}
// Flat chain (no nesting):
loadConfig((err, config) => {
if (err) return console.error(err);
loadTemplate(config, (err, template) => {
if (err) return console.error(err);
writeOutput(config, template, (err) => {
if (err) return console.error(err);
console.log("done");
});
});
});
Production Pattern: Concurrency with queueMicrotask
javascript
// ── queueMicrotask: schedule a microtask without a Promise ──
// Lower latency than setTimeout(fn, 0) — runs before the next macrotask.
// Use case: batch synchronous work into microtasks (yield to the event loop faster)
function batchProcess(items, processor) {
let i = 0;
function processChunk() {
const chunk = items.slice(i, i + 1000);
for (const item of chunk) processor(item);
i += 1000;
if (i < items.length) {
queueMicrotask(processChunk); // schedule next chunk as a microtask
}
}
processChunk();
}
// ── Use case: schedule after current synchronous work, before render ──
queueMicrotask(() => {
// Runs after current sync code, before the browser paints
// Useful for updating DOM without causing a flash
element.textContent = "updated";
});
// ── Comparison: queueMicrotask vs setTimeout ──
queueMicrotask(() => console.log("microtask")); // runs after current task, before next macrotask
setTimeout(() => console.log("macrotask"), 0); // runs after next macrotask
console.log("sync");
// Output: sync, microtask, macrotask
// Microtask is higher priority (runs first, before setTimeout)
Anti-Pattern: Blocking the Event Loop
javascript
// ❌ NAIVE — synchronous CPU-bound work blocks the event loop
function heavyCompute(n) {
let sum = 0;
for (let i = 0; i < n; i++) sum += Math.sqrt(i);
return sum;
}
// heavyCompute(1_000_000_000); // blocks the event loop for seconds — no UI updates, no timers
// ✅ CORRECT — chunk the work with setTimeout to yield to the event loop
async function chunkedCompute(n, chunkSize = 10_000) {
let sum = 0;
let i = 0;
return new Promise(resolve => {
function chunk() {
const end = Math.min(i + chunkSize, n);
for (; i < end; i++) sum += Math.sqrt(i);
if (i < n) {
setTimeout(chunk, 0); // yield — lets timers/UI/IO run between chunks
} else {
resolve(sum);
}
}
chunk();
});
}
// chunkedCompute(1_000_000_000).then(console.log); // UI stays responsive
// ✅ BEST — use a Web Worker (true parallelism, doesn't block the main thread)
// main.js:
// const worker = new Worker("./compute-worker.js");
// worker.postMessage(1_000_000_000);
// worker.onmessage = (e) => console.log("result:", e.data);
// compute-worker.js:
// self.onmessage = (e) => {
// let sum = 0;
// for (let i = 0; i < e.data; i++) sum += Math.sqrt(i);
// self.postMessage(sum);
// };
💡 Tips & Tricks
javascript
// ── Promise.resolve().then() as a microtask scheduler ──
Promise.resolve().then(() => console.log("microtask via Promise"));
// Same as queueMicrotask but uses a Promise (slightly more overhead, but more compatible)
// ── scheduler.yield() (Chrome 129+) — modern way to yield ──
async function yieldToEventLoop() {
if (typeof scheduler !== "undefined" && scheduler.yield) {
await scheduler.yield(); // higher priority than setTimeout (runs sooner)
} else {
await new Promise(r => setTimeout(r, 0)); // fallback
}
}
// ── AbortController for cancellable async operations ──
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // abort after 5s
fetch("/api/data", { signal: controller.signal })
.then(r => r.json())
.catch(err => {
if (err.name === "AbortError") console.log("aborted");
else throw err;
});
// ── Measuring event loop latency (Node.js) ──
// Node only:
// let last = process.hrtime.bigint();
// setInterval(() => {
// const now = process.hrtime.bigint();
// const lag = Number(now - last) / 1e6 - 1000; // ms over expected
// console.log("event loop lag:", lag, "ms");
// last = now;
// }, 1000);
⚠️ Edge Cases & Gotchas
javascript
// ── setTimeout(fn, 0) doesn't run immediately — it's a macrotask ──
// It runs after the current script AND all queued microtasks complete.
console.log("A");
setTimeout(() => console.log("C"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("D");
// Output: A, D, B, C (B is microtask — runs before C which is macrotask)
// ── setInterval drift: timer doesn't fire at exact intervals ──
// setInterval fires AFTER the macrotask queue processes it, not at exact intervals.
// If the event loop is busy, intervals are delayed.
// Use a self-correcting timer:
let expected = Date.now();
function accurateInterval(fn, delay) {
function tick() {
const drift = Date.now() - expected;
fn(drift); // pass drift so the callback can adjust
expected += delay;
setTimeout(tick, Math.max(0, delay - drift)); // correct for drift
}
setTimeout(tick, delay);
}
// ── Microtasks can starve macrotasks (infinite loop) ──
// A microtask that adds another microtask never lets macrotasks run.
// Don't create infinite microtask chains.
// ── `await` yields to the event loop (it's a microtask boundary) ──
async function f() {
console.log("A");
await Promise.resolve(); // yields — microtasks/timers can run here
console.log("B");
}
f();
console.log("C");
// Output: A, C, B (await suspends, C runs, then B resumes as a microtask)
// ── `requestAnimationFrame` runs before paint, not on the event loop tick ──
// rAF callbacks run in the render phase (after microtasks, before paint).
// They're not macrotasks — they run at the browser's frame rate (~60fps).
🧠 Quick Quiz
What's the output order?
javascript
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
queueMicrotask(() => console.log("5"));
Answer
1
4
3
5
2
1— synchronous (script is the current macrotask)4— synchronous (continues)3— microtask (Promise.then — drained after script)5— microtask (queueMicrotask — same microtask drain)2— macrotask (setTimeout — runs after all microtasks are drained)
The lesson: all microtasks (Promise.then, queueMicrotask) run before the next macrotask (setTimeout). The microtask queue is fully drained before the event loop proceeds to the next macrotask.