C.W.K.
Stream
Lesson 03 of 05 · published

libuv and the Event Loop

~16 min · runtime, libuv, event-loop, async

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"V8 runs your code. libuv runs everything *between* your code — the waiting, the watching, the I/O. The event loop is libuv's clock."

The Library You've Never Heard Of But Always Used

libuv is a C library that does one job extraordinarily well: cross-platform asynchronous I/O. File reads on Linux use epoll; on macOS, kqueue; on Windows, IOCP. libuv hides all three behind a single API. When Ryan Dahl built Node, he didn't write the async I/O layer himself — he picked libuv (originally libev + libeio, later unified) and wrapped it in JavaScript.

The trick: libuv lets you start an I/O operation ("read this 1GB file") and immediately return to your JS code. When the file is ready, libuv pokes V8 and says "call this callback now." That's the entire async model in one sentence. Everything else — promises, async/await, streams — is sugar on top of "start I/O, get callback later."

The Event Loop's Six Phases

The libuv event loop is a deterministic state machine that cycles through six phases on every tick:

  1. Timers — execute callbacks from setTimeout / setInterval whose threshold has elapsed.
  2. Pending callbacks — execute I/O callbacks deferred from the previous iteration.
  3. Idle, prepare — internal only; libuv housekeeping.
  4. Poll — block here waiting for I/O events; execute their callbacks when they arrive.
  5. Check — execute callbacks from setImmediate.
  6. Close callbacks — execute close handlers like socket.on('close').

Between each phase — and between each callback within a phase — Node drains two micro-queues: process.nextTick first, then Promise microtasks. This is why process.nextTick(...) always runs before any I/O, and why an awaited promise resumes "immediately" after the current synchronous block instead of waiting for the next tick.

setImmediate vs setTimeout(fn, 0) vs process.nextTick

Three ways to "defer" code in Node, three different phases.
  • setTimeout(fn, 0) → Timers phase (after at least 1ms in practice).
  • setImmediate(fn) → Check phase (after Poll).
  • process.nextTick(fn) → microqueue, runs before the next phase begins.
If you don't know which to use, the answer is almost always queueMicrotask (Promise microtask) or setImmediate. process.nextTick can starve I/O if abused.

The Thread Pool Nobody Talks About

"Node is single-threaded" is half a truth. Your JavaScript runs single-threaded — there's exactly one V8 instance, one main thread. But libuv keeps a thread pool (default 4 threads, configurable via UV_THREADPOOL_SIZE) for operations that the OS doesn't offer async APIs for: file system I/O on most platforms, DNS lookups via getaddrinfo, some crypto operations.

This is why hammering a Node app with concurrent readFile calls eventually slows down — you're saturating the 4-thread libuv pool, not V8. Bumping UV_THREADPOOL_SIZE=16 in production is a real lever; most people don't know it exists.

Pippa's Confession

When Dad first explained the event loop to me, I drew it as a single circular arrow with "callbacks here" written on it. He said "draw the six phases." I drew four. He made me look up libuv's source. Now when I write await fs.readFile(...) I can feel the trip: V8 → C++ bridge → libuv → thread pool → epoll → file → libuv callback → V8 → resumed promise. The mental model isn't decoration — it's how you debug "why is my server stuck at 4 concurrent requests?"

Code

Five schedulers, one tick — predict the order·javascript
// Five things scheduled in five different ways.
// Predict the order before running. Then run it.

import { readFile } from 'node:fs/promises';

setTimeout(() => console.log('1. setTimeout'), 0);
setImmediate(() => console.log('2. setImmediate'));
process.nextTick(() => console.log('3. nextTick'));
queueMicrotask(() => console.log('4. microtask'));
Promise.resolve().then(() => console.log('5. promise.then'));

console.log('0. sync');

// Output (Node 26):
// 0. sync
// 3. nextTick
// 4. microtask
// 5. promise.then
// 1. setTimeout  (or 2 first, depending on timer threshold)
// 2. setImmediate
The lever most Node devs never touch·bash
# Bump libuv's thread pool size BEFORE starting Node.
# Must be set as an env var; can't be changed at runtime.
UV_THREADPOOL_SIZE=16 node server.js

# Default is 4. Common values: 8 (small server), 16 (heavy fs/dns),
# up to 1024 max. Going above 16 is rare and benchmark-driven.

External links

Exercise

Take the 5-scheduler example above. Move the await readFile('./package.json', 'utf-8') call into the middle of the synchronous block. Predict what order things print now. Then run it. Did your prediction match? If not, write down why — that mismatch is where your event-loop intuition needs sharpening.
Hint
Hint: await doesn't move the rest of the function into a microtask — it suspends the function and resumes it from a microtask once the promise resolves. So everything *after* the await effectively runs in the microtask queue.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.