DevLift
Back to Blog

How the Node.js Event Loop Works Under the Hood

You've read "Node.js is single-threaded and non-blocking." Fine. But that sentence raises more questions than it answers. If it's single-threaded, who's doing the file I/O while your JavaScript runs?

Admin
August 2, 20268 min read2 views

How the Node.js Event Loop Works Under the Hood

You've read "Node.js is single-threaded and non-blocking." Fine. But that sentence raises more questions than it answers. If it's single-threaded, who's doing the file I/O while your JavaScript runs? If it's non-blocking, why does a CPU-heavy while loop freeze your entire server? And why does setImmediate sometimes fire before setTimeout(fn, 0) but not always?

The answers live inside libuv — the C library Node.js uses as its async I/O engine — and in the six-phase loop it runs. Once you understand the loop mechanically, those "JavaScript is weird" moments stop being surprises and become predictions.

The Mental Model

Node.js is not one thread. It's one JavaScript thread plus a pool of OS threads managed by libuv.

Rendering diagram...

Your JavaScript runs on a single thread. When it hits an async operation — fs.readFile, https.get, crypto.pbkdf2 — it hands the work off to libuv, which either delegates to the OS's native async polling (for network I/O) or pushes it to a thread pool (for file I/O and CPU-bound work). When the operation completes, libuv queues the callback. The event loop's job is to drain those queues in the right order.

The Six Phases

libuv's event loop is not one queue. It's six sequential phases, each with its own set of pending callbacks — FIFO for most of them, a min-heap ordered by expiry for timers. The loop visits the phases in order, drains or partially drains each one, then moves to the next.

Rendering diagram...

Phase 1 — Timers

setTimeout(fn, 100) doesn't mean "run in exactly 100ms." It means "run no sooner than 100ms." libuv stores active timers in a min-heap sorted by expiry time. At the start of the timers phase, the loop pops any timers whose threshold has passed and fires their callbacks.

OS scheduling jitter, the time spent in previous phases, and poll-phase blocking all push the actual execution time later than the threshold. That's why setTimeout(fn, 0) is not the same as "run now."

Phase 2 — Pending Callbacks

Some I/O errors — specifically TCP errors like ECONNREFUSED — are deferred from the previous loop iteration. That's what this phase is for. You rarely interact with it directly.

Phase 3 — Idle / Prepare

Internal libuv bookkeeping. Not visible to JavaScript. Skip it.

Phase 4 — Poll (where most of the action is)

The poll phase does two things:

  1. Run I/O callbacks — if there are completed I/O events waiting, execute their callbacks synchronously until the queue is empty or a system-level callback limit is reached.
  2. Block waiting for I/O — if the callback queue is empty, the loop calculates how long it can sleep and calls the platform's native polling API with that timeout.

The blocking duration is whatever's left until the nearest timer threshold expires. If no timers are pending, the loop is free to block indefinitely — until the kernel wakes it with an I/O event. If a timer is due in 8ms, that's the ceiling on the sleep.

If setImmediate() has been scheduled, the block time is zero — the loop skips the wait and moves to check immediately. This is why setImmediate reliably fires after I/O callbacks: those callbacks run in poll, then check runs right after.

The actual blocking call differs by OS:

Linux  → epoll_wait(epfd, events, maxevents, timeout_ms)
macOS  → kevent(kq, NULL, 0, events, nevents, timespec)
Windows → GetQueuedCompletionStatusEx(iocp, ...)

When an I/O operation completes — a socket receives data, a file read finishes — the kernel wakes up the poll call and libuv translates the event into a callback enqueue.

Phase 5 — Check

setImmediate() callbacks run here, always after poll. If you schedule setImmediate inside an I/O callback (which runs in poll), it will always fire in this check phase — before any timers whose threshold may have already elapsed.

const fs = require('fs');
 
fs.readFile('/etc/hosts', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
 
// Output is always:
// immediate
// timeout

That "always" is worth taking literally: 200 out of 200 trials on Node 22.22.3.

Outside an I/O callback there is no guarantee, because whether the 1ms timer has already expired by the time the loop first reaches the timers phase depends on how long process startup took. In practice it's heavily skewed rather than genuinely even — 60 fresh processes, each doing nothing but setTimeout(f, 0) and setImmediate(g) at the top of the main module, gave setImmediate first 59 times and setTimeout first once. Skewed is not the same as guaranteed. Don't write code that depends on it.

Phase 6 — Close Callbacks

When you call socket.destroy() or stream.on('close', ...), those close events fire here. If the handle was closed gracefully, it might use process.nextTick() instead — but abrupt closes land in this phase.

libuv's Thread Pool

Network I/O (TCP, UDP, pipes) is handled entirely via the OS's async APIs. No threads involved — the kernel does the waiting.

File I/O is different. POSIX open() and read() are technically blocking. libuv works around this by pushing file operations to a thread pool: 4 worker threads by default. Each thread does the blocking call and signals the event loop when done.

// These go to the thread pool:
fs.readFile(...)         // blocking read on a worker thread
dns.lookup(...)          // getaddrinfo() is blocking on most OSes
crypto.pbkdf2(...)       // CPU-intensive, pool prevents blocking JS thread
zlib.gzip(...)           // CPU-intensive
 
// These use the OS async API (no thread pool):
net.createServer(...)    // TCP via epoll/kqueue
http.request(...)        // TCP sockets

You can tune the pool size:

UV_THREADPOOL_SIZE=16 node server.js

Default is 4. The ceiling is 1024, not 128 — libuv raised MAX_THREADPOOL_SIZE from 128 to 1024 in 1.45.0, and Node 22 ships libuv 1.51. Verified by counting OS threads in /proc/self/status while 300 pbkdf2 calls were in flight on Node 22.22.3:

UV_THREADPOOL_SIZE unset  -> 4 pool threads
UV_THREADPOOL_SIZE=130    -> 130 pool threads
UV_THREADPOOL_SIZE=200    -> 200 pool threads
UV_THREADPOOL_SIZE=1024   -> 1024 pool threads
UV_THREADPOOL_SIZE=2000   -> 1024 pool threads   (clamped)

If all threads are busy, incoming file I/O requests queue up — this is why a Node server with heavy concurrent file access can appear to stall even though the event loop is running.

You can watch the split yourself. Saturate all four default threads with crypto.pbkdf2('s','s',2_000_000,...), then fire one of each kind of operation and log when it completes:

net.connect (TCP)      completed at +35ms     <- OS async API, unaffected
dns.lookup             completed at +471ms    <- waited for a pool thread
crypto.randomBytes     completed at +471ms
zlib.gzip              completed at +472ms
fs.readFile            completed at +473ms

Sockets don't queue. Everything that needs a worker thread does.

Microtasks: The Layer Above the Phases

Here's where most of the confusion lives. There are two queues that don't belong to any libuv phase — they're drained by Node.js itself between every phase transition:

  1. process.nextTick() queue — higher priority
  2. Promise microtask queue (resolved .then() callbacks, await continuations)

After every callback in any phase, before the loop advances, Node checks these queues and drains them completely. Including any tasks they enqueue — this runs to exhaustion.

Rendering diagram...
console.log('start');
 
setTimeout(() => console.log('timeout'), 0);
 
Promise.resolve().then(() => console.log('promise'));
 
process.nextTick(() => console.log('nextTick'));
 
console.log('end');
 
// Output:
// start
// end
// nextTick
// promise
// timeout

nextTick fires before the promise because it has higher priority. Both fire before setTimeout because they both fire before the event loop advances to the timers phase.

⚠️

That output holds for CommonJS. Save the same file as .mjs and you get promise, nextTick, timeout instead — on Node 22.22.3, measured. An ES module body is evaluated inside a microtask, so the microtask queue that's already draining picks up your .then() callback before control returns to the point where the nextTick queue is processed. If you're teaching this ordering from a REPL or a .cjs scratch file and your app is ESM, you will confuse yourself. The relative priority of the two queues hasn't changed; the starting position has.

💡

Recursive process.nextTick() calls — calling nextTick inside a nextTick callback — will starve the event loop. libuv phases never advance. I/O callbacks never run. The process appears hung, and Node imposes no depth limit. A 1500ms recursion on Node 22.22.3 ran 10.8 million ticks, and an fs.readFile callback registered before the recursion started had still not fired when it ended.

Why This Matters: Practical Implications

1. CPU work blocks everything

The event loop is a cooperative scheduler. If your JavaScript runs for 500ms without returning, the loop can't advance to the next phase. No I/O callbacks fire. No timers fire. Incoming HTTP requests queue up waiting for the thread to become available.

// This blocks the event loop for ~2 seconds
function blockingWork() {
  const start = Date.now();
  while (Date.now() - start < 2000) {}
}
 
app.get('/data', (req, res) => {
  blockingWork(); // All other requests stall here
  res.json({ ok: true });
});

For CPU-heavy work, push it to a worker thread with worker_threads, or use setImmediate to yield back to the event loop between chunks.

2. setImmediate vs setTimeout(fn, 0) — pick intentionally

setTimeout(fn, 0) has a minimum clamped threshold of 1ms — chaining 50 of them on Node 22.22.3 gave a median gap of 1.37ms, never below 1.12ms. It fires in the timers phase. setImmediate fires in the check phase, always after poll. Inside I/O callbacks, setImmediate is deterministically first. Outside I/O, it's whichever way startup timing falls — usually setImmediate, but not something to rely on.

If you're breaking up a heavy computation loop, setImmediate is safer because it yields to poll first — allowing I/O callbacks to run between chunks:

function processChunks(chunks, index = 0) {
  if (index >= chunks.length) return;
  doHeavyWork(chunks[index]);
  setImmediate(() => processChunks(chunks, index + 1));
}

3. Thread pool saturation shows up as latency, not errors

// 4 concurrent pbkdf2 calls → fine (4 threads)
// 5 concurrent pbkdf2 calls → 5th waits for a free thread
// No errors thrown. Just latency.
 
for (let i = 0; i < 10; i++) {
  crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', (err, key) => {
    console.log(`Hash ${i} done`);
  });
}
// With UV_THREADPOOL_SIZE=4, hashes 0-3 start immediately.
// Hashes 4-9 start only as threads free up.

Bump UV_THREADPOOL_SIZE if you're doing a lot of concurrent file I/O or crypto. Don't bump it past your CPU core count for CPU-bound work — you'll add context-switching overhead with no benefit.

4. process.nextTick() for callback ordering, not I/O deferral

process.nextTick() is for making APIs consistently asynchronous. Use it when you need a callback to fire after the current synchronous block but before the event loop advances:

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    // Without nextTick, emit fires before the caller can attach .on('event')
    process.nextTick(() => this.emit('ready'));
  }
}

Don't use nextTick to defer I/O work. Use setImmediate. If you call nextTick in a tight loop, you will starve the loop. That's not a theoretical risk — it's a production gotcha.

The Loop in 30 Seconds

  1. Synchronous code runs to completion.
  2. nextTick queue drains. Then Promise queue drains.
  3. libuv enters timers phase: fires expired setTimeout/setInterval callbacks.
  4. Between every callback: drain nextTick, then Promise queue.
  5. Pending callbacks phase: deferred I/O errors.
  6. Idle/prepare: internal bookkeeping.
  7. Poll phase: run ready I/O callbacks; if none, block waiting for I/O (up to the next timer threshold).
  8. Check phase: setImmediate callbacks.
  9. Close callbacks: socket/stream close handlers.
  10. Go to step 3.

That's it. Every "JavaScript is async magic" behavior you've ever seen falls out of these 10 steps — the timer drift, the setImmediate ordering guarantee, the nextTick starvation footgun, all of it.

The deeper takeaway: Node's performance model is about throughput, not latency. If you keep callbacks short and CPU work off the main thread, libuv phases cycle fast and your server handles thousands of concurrent connections with four OS threads. The moment JavaScript stays on the call stack too long, the whole pipeline backs up.

Know where your time goes.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read