The event loop (deep)
Synchronous code runs to completion, then the entire microtask queue drains, then one macrotask runs, then microtasks drain again, then rendering (in the browser). Node groups macrotasks into phases. A flood of microtasks can starve rendering and I/O — and that's a real failure mode.
Long read · browser model + Node phases + libuv mechanics
1 · The fundamental loop
console.log("1 sync");
setTimeout(() => console.log("5 macrotask"), 0);
Promise.resolve()
.then(() => console.log("3 microtask 1"))
.then(() => console.log("4 microtask 2"));
console.log("2 sync");Sync first. Then all microtasks (each .then queues the next when it
resolves). Then a single macrotask. If that macrotask schedules more microtasks, they
drain again before the next macrotask. Microtask draining is exhaustive.
2 · Microtask sources
| API | Queue |
|---|---|
Promise.then / .catch / .finally | microtask |
queueMicrotask(fn) | microtask (explicit) |
MutationObserver (browser) | microtask |
process.nextTick (Node) | before microtasks — even sooner |
process.nextTick is a Node-specific super-microtask: it drains before the
regular microtask queue. Useful, but easy to starve everything else with — a recursive
nextTick never lets the loop advance.
3 · Macrotask sources
- Browser:
setTimeout,setInterval, I/O callbacks,postMessage, user input. - Node: Each phase handles a different macrotask category — timers, I/O, immediate, close.
4 · The Node phases
libuv (Node's loop) cycles through phases, executing all ready callbacks in each:
| Phase | What it does |
|---|---|
| Timers | Expired setTimeout / setInterval callbacks. |
| Pending | OS-level callbacks deferred from previous loop (TCP errors, etc.). |
| Idle, prepare | Internal use only. |
| Poll | Wait on file descriptors (epoll/kqueue). The longest phase. |
| Check | setImmediate callbacks. |
| Close | 'close' events (socket close, etc.). |
Between each phase, the microtask queue (and process.nextTick) drains. Hence: in Node, microtasks can run many times per macrotask "phase".
5 · Rendering — browser only
The browser interleaves rendering into the loop: after each macrotask, before the next
one, the browser may run animation frames (requestAnimationFrame),
style/layout/paint passes, and ResizeObserver callbacks. Microtasks drain after each step.
If you run synchronous code for 100ms, the page can't render and can't accept input for 100ms. If you queue microtasks that re-queue themselves, you've created the same outage in a different shape.
// Starves rendering — never gives the browser a chance
function spin() {
queueMicrotask(spin);
}
spin();
// Doesn't starve — yields to a macrotask, lets rendering happen
function spinPolite() {
setTimeout(spinPolite, 0);
}6 · setImmediate vs setTimeout(0) in Node
// From the top of the loop, order is non-deterministic
setImmediate(() => console.log("immediate"));
setTimeout(() => console.log("timeout"), 0);
// From inside an I/O callback, setImmediate ALWAYS runs first
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// → "immediate" first, then "timeout"Reason: an I/O callback runs in the Poll phase. setImmediate runs in the Check phase (immediately after Poll). setTimeout(0) waits for the next Timers phase.
7 · Yielding for long work
// Bad — blocks the loop entirely
function processSync(items) {
for (const it of items) heavy(it);
}
// Better — chunk + yield, lets the loop process other tasks
async function processChunked(items, chunkSize = 50) {
for (let i = 0; i < items.length; i += chunkSize) {
const end = Math.min(i + chunkSize, items.length);
for (let j = i; j < end; j++) heavy(items[j]);
await new Promise(r => setTimeout(r, 0)); // yield via macrotask
}
}
function heavy(x) { /* CPU work */ }For real CPU work in the browser, move it to a Web Worker. For Node, worker_threads. The main loop is one thread.
8 · Debugging
- Browser: Performance panel → Main thread flame chart. Long tasks show as red bars. Schedule them.
- Node:
--inspect+ Chrome devtools.--proffor V8 sampling.perffor OS-level. - Microtask floods: If RAF/rendering is stalled but CPU is busy, you're starving the loop.
- process.nextTick recursion: Same outcome; Node will eventually warn about a "blocked event loop".
References
- WHATWG: Event loops — The normative browser spec.
- Node: event loop, timers, nextTick — The official Node primer.
- Jake Archibald — In the Loop — 2018 JSConf Asia talk; the best 30 min you can spend.
- libuv/src/unix/core.c — uv_run — The implementation of Node loop.
- Matteo Collina — the Node event loop — Practical view from a Node core member.