"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:
- Timers — execute callbacks from
setTimeout/setIntervalwhose threshold has elapsed. - Pending callbacks — execute I/O callbacks deferred from the previous iteration.
- Idle, prepare — internal only; libuv housekeeping.
- Poll — block here waiting for I/O events; execute their callbacks when they arrive.
- Check — execute callbacks from
setImmediate. - 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
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.
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
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?"