Browser vs Node — Where the Event Loop Actually Diverges (Part 2/3)
In part 1, we built the shared mental model: call stack, microtask queue, macrotask queue, and the rule that microtasks fully drain before the next macrotask runs. That model is spec-level JavaScript behavior — but it's not the whole story once you actually run code. The event loop isn't part of the JS language spec. It's part of the host environment — the browser or Node — and each one implements it differently around that shared core. This is the post most "event loop" explainers skip, because it means going past the diagram and into how each runtime is actually built. The browser: event loop meets rendering In a browser, the event loop isn't just juggling callbacks — it's also responsible for keeping the page visually responsive. That means rendering has to get a turn too, and the browser has to decide when. Here's the roughly accurate sequence per loop iteration: Execute one macrotask (a click handler, a setTimeout callback, a network event, whatever's next in the queue) Drain the entire microtask queue Maybe render a frame — the browser doesn't render after every single task; it tries to hit ~60fps and will batch work between paints Go back to step 1 The "maybe render" part is where two APIs come in that don't exist in Node at all: requestAnimationFrame(callback) — schedules a callback to run right before the next repaint. It's not a macrotask or microtask in the queue sense — it's tied directly to the rendering pipeline. Use it for anything visual (animations, DOM measurements) instead of setTimeout, because it's synced to when the browser is actually about to paint, not an arbitrary delay. requestIdleCallback(callback) — schedules a callback to run when the browser is idle, after layout and paint, with a deadline. Meant for low-priority work you don't want competing with rendering — analytics, prefetching, non-urgent DOM updates. Here's the key interaction that's easy to miss: microtasks can starve rendering. If a promise chain keeps queueing more microtasks, the browser can't get to the paint step, because microtasks always drain fully before rendering gets a turn. This is a real, debuggable performance bug — a runaway .then() chain can visibly freeze a page even though "nothing is blocking the main thread" in the traditional sync-loop sense. function recursiveMicrotask() { Promise.resolve().then(recursiveMicrotask); } recursiveMicrotask(); // Page becomes unresponsive — not because the stack is blocked, // but because the microtask queue never empties long enough for a paint. Compare that to a setTimeout-based recursive loop — because each iteration is a separate macrotask, the browser gets a chance to render between them. Node: no rendering, but a much more structured loop Node doesn't render anything, so it doesn't need the "maybe paint" logic. Instead, it's built on libuv, a C library that gives Node its event loop, thread pool, and async I/O. libuv organizes the loop into distinct phases, each with its own FIFO queue of callbacks. This is a meaningfully different shape from the browser's single task queue. The phases, in order, each loop tick: timers — runs callbacks scheduled by setTimeout / setInterval whose threshold has elapsed pending callbacks — executes I/O callbacks deferred from the previous loop iteration (some system-level TCP errors, etc.) idle, prepare — internal use only poll — the big one: retrieves new I/O events, executes I/O-related callbacks (almost everything — file reads, network requests). Node will block here waiting for new events if there's nothing else scheduled check — setImmediate() callbacks run here, specifically after poll close callbacks — e.g. socket.on('close', ...) Then it loops back to timers. Between every single callback — not just between phases, but between individual callbacks within a phase — Node drains the microtask queue. Same drain-fully rule as the browser, just applied at a finer grain because there's no rendering to interleave with. process.nextTick() — Node's queue-jumper Node has a queue that doesn't exist in the browser at all: process.nextTick(). Despite the name, it doesn't queue for "next tick" of the event loop — it runs before microtasks, after the current operation finishes, no matter what. Priority order in Node, after any synchronous code completes: process.nextTick() queue (fully drained) Promise microtask queue (fully drained) Next macrotask/phase callback setTimeout(() => console.log('timeout'), 0); process.nextTick(() => console.log('nextTick')); Promise.resolve().then(() => console.log('promise')); console.log('sync'); Output: sync, nextTick, promise, timeout nextTick beats the promise every time, because Node checks and drains the nextTick queue first, and — same recursion risk as the microtask-starvation example above — a recursive process.nextTick() call can starve I/O entirely, since Node won't proceed past it to the poll phase. setImmediate() vs setTimeout(fn, 0) — the ambiguous one This is the example that shows up in almost every Node interview, and the honest answer is: it depends on where you call it from. setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); Run this at the top level of a script, and the order is not guaranteed — it depends on process startup timing, specifically whether the timers phase's threshold has already elapsed by the time the loop starts. You'll see it flip between runs. But inside an I/O callback, the order is deterministic: const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); }); Output is always immediate, then timeout. Why? Because fs.readFile's callback runs in the poll phase. From there, the loop moves to check next — where setImmediate lives — before it wraps back around to timers. The phase order guarantees it here, where at the top level there's no such guarantee. This is a genuinely useful thing to internalize: setImmediate means "run in the check phase, this loop iteration," while setTimeout(fn, 0) means "run in the timers phase, next time the loop gets there" — and those are different guarantees depending on what phase you're currently in when you schedule them. Side-by-side: browser vs Node Concept Browser Node Underlying engine V8 (Chrome), SpiderMonkey (Firefox), etc. V8 + libuv Queue structure Single task queue + microtask queue Multiple phase-specific queues (libuv) + microtask queue Rendering concern Yes — interleaved between tasks No rendering at all setTimeout(fn, 0) Runs as next macrotask, after microtasks drain Runs in the timers phase, next time loop reaches it setImmediate() Doesn't exist Runs in the check phase process.nextTick() Doesn't exist Runs before microtasks, after current operation Idle/low-priority work requestIdleCallback() No direct equivalent — usually just setImmediate() or offloading Animation timing requestAnimationFrame() No equivalent — no rendering to sync to I/O model Web APIs (fetch, XHR, DOM events) libuv (thread pool for fs, async for network) What carries over, and what doesn't The stack/microtask/macrotask model from part 1 is real in both environments — that part's spec-driven and doesn't change. But the shape of the macrotask side is genuinely different: the browser has one queue and a rendering step to juggle; Node has an ordered set of phases with distinct semantics per phase, plus process.nextTick() sitting in front of everything. If you've been assuming "the event loop" is one universal thing you can reason about the same way in a React component and an Express handler, this is usually where that assumption breaks — and where subtle bugs (starved I/O from recursive nextTick, or a setTimeout-vs-setImmediate race at startup) actually come from in production code. Coming up next Both of these models assume your callbacks are fast. But what happens when you genuinely have CPU-heavy work — image processing, large computations, parsing — that can't be broken into tiny async chunks? No amount of clever queue ordering saves you if a single synchronous function blocks the thread for 500ms. Part 3 covers Node's answer to that: worker_threads — real OS-level threads, how to actually use them, and when reaching for a worker beats just optimizing your algorithm.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to