Internals
Internals

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

js order.js
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

APIQueue
Promise.then / .catch / .finallymicrotask
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:

PhaseWhat it does
TimersExpired setTimeout / setInterval callbacks.
PendingOS-level callbacks deferred from previous loop (TCP errors, etc.).
Idle, prepareInternal use only.
PollWait on file descriptors (epoll/kqueue). The longest phase.
ChecksetImmediate 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.

js starve.js
// 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

js node-timing.js
// 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

js yield.js
// 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. --prof for V8 sampling. perf for 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

Found this useful?