Node.js Internals — Master Reference

THE
EVENT
LOOP

A byte-level, phase-by-phase dissection of how Node.js processes asynchronous I/O, timers, microtasks, and native callbacks — from V8 heap to libuv epoll, with zero hand-waving.

SCROLL TO EXPLORE

01 — Architecture Overview

WHAT DRIVES THE LOOP

Node.js is a single-threaded, non-blocking I/O runtime built on two pillars: V8 (Google's JavaScript engine, handling parsing, compilation via Ignition/TurboFan, and garbage collection) and libuv (a cross-platform async I/O library written in C, providing the event loop, thread pool, and OS abstraction). Between them sits the Node.js binding layer — a set of C++ addons that expose libuv handles and V8 callbacks.

When you write fs.readFile(), Node calls into C++, which calls libuv, which issues an OS syscall (e.g. io_uring_submit on Linux or kqueue on macOS), and parks the request. The JavaScript thread is immediately free. When the OS signals completion, libuv queues a callback, and the event loop picks it up on its next relevant tick.

⬡ JavaScript / V8
V8 Ignition Bytecode

Interprets JS → bytecode. Profiles hot paths for TurboFan JIT compilation. Manages hidden classes, inline caches.

V8 TurboFan JIT

Compiles hot bytecode → optimized machine code using speculative deoptimization. Deopt triggers re-entry to Ignition.

V8 Orinoco GC

Generational, tri-color mark-sweep. New-space (32MB) uses Scavenger. Old-space uses concurrent incremental marking to minimize stop-the-world pauses.

NODE Bindings Layer

C++ code (e.g. node_file.cc) using the V8 C++ API to expose native capabilities. process.binding() is the old path; internalBinding() is the modern one.

⟲ libuv Event Loop Phases

Executed in strict sequential order per iteration ("tick")

01 timers

Executes setTimeout / setInterval callbacks whose threshold has expired. Checks libuv's min-heap of timers. The delay is a minimum, not a guarantee — depends on system load and prior phase duration.

02 pending callbacks

Executes I/O callbacks deferred from the previous iteration — specifically, TCP errors from ECONNREFUSED and similar system-level errors that libuv couldn't report immediately.

03 idle / prepare

Internal libuv housekeeping. uv__run_idle handles are invoked. These run on every loop tick and are used internally by Node.js (e.g. to keep the loop alive). Not directly accessible from JS.

04 poll

The most critical phase. Calls into the OS I/O multiplexer (epoll_wait / kqueue / IOCP). Blocks if no other phases have work, up to a calculated timeout. New I/O callbacks are executed here.

05 check

setImmediate() callbacks fire here. Always runs after poll, even if poll phase was blocked. libuv uses a uv__io_check handle for this. Preferred over setTimeout(fn, 0) for post-I/O scheduling.

06 close callbacks

Fires close events for handles that were closed abruptly — e.g. socket.destroy(). Emits 'close' events on streams and sockets. Final phase before the loop re-evaluates whether to continue.

⚙ libuv + OS Kernel
// I/O DEMULTIPLEXER
Linux → epoll (io_uring in newer)
macOS / BSD → kqueue
Windows → IOCP (completion ports)
Solaris → Event ports
// THREAD POOL (default: 4)
fs.* blocking calls
crypto (pbkdf2, randomBytes)
dns.lookup() (not resolve)
zlib (compression ops)
user native addons
// MICROTASK QUEUES (V8, not libuv)
process.nextTick queue
Promise microtask queue
⚠ Drain completely between each phase transition
// HANDLE TYPES
uv_tcp_t — TCP sockets
uv_udp_t — UDP sockets
uv_timer_t — timers
uv_fs_event_t — file watch
uv_process_t — child procs

02 — Microtask Queues

THE INVISIBLE QUEUES

Microtasks do not belong to the event loop phases — they are a V8-level concept drained by the runtime after every C++ → JS boundary transition, i.e., between each phase transition and after each individual macro callback completes. There are two microtask queues, and they have strict priority ordering.

timers
setTimeout
setInterval
min-heap
nextTick
Promise
pending cbs
deferred
I/O errs
linked list
nextTick
Promise
idle/prepare
internal
handles
uv handles
nextTick
Promise
poll
I/O events
OS notify
epoll/kqueue
nextTick
Promise
check
setImmediate
uv_check_t
nextTick
Promise
close cbs
socket.close
stream end
linked list
nextTick
Promise
⚠
Starvation Risk: process.nextTick() callbacks can starve the event loop. If a nextTick callback schedules another nextTick, the entire queue must drain before moving to any phase — including I/O. This is why Node.js added a --max-listeners style warning via process.maxTickDepth (now removed, but the issue remains). Use setImmediate() for recursive deferred work to yield to I/O.
microtask-priority.js — predict the output
// Execution order quiz — a senior engineer's litmus test
console.log('1 — sync');

setTimeout(() => console.log('2 — setTimeout'), 0);

Promise.resolve().then(() => {
  console.log('3 — promise microtask');
  process.nextTick(() => console.log('4 — nextTick inside promise'));
});

process.nextTick(() => {
  console.log('5 — nextTick');
  Promise.resolve().then(() => console.log('6 — promise inside nextTick'));
});

setImmediate(() => console.log('7 — setImmediate'));

console.log('8 — sync end');
✓
Output: 1 → 8 → 5 → 6 → 3 → 4 → 2 → 7

Why: Sync completes (1, 8). Then microtasks drain: nextTick queue runs first → prints 5, schedules a Promise. Then Promise queue runs: the Promise from line 3 → prints 3, schedules a nextTick. nextTick queue is not empty again — it now has two items. Priority flips back to nextTick: prints 4. Then Promise from inside nextTick: prints 6. All microtasks exhausted. Event loop enters timers phase (setTimeout → 2). Then check phase (setImmediate → 7).

Note: Prior to Node.js 11, nextTick and Promise queues were drained only between phases, not after each individual callback. Node 11+ changed this to match browser behavior.

03 — Interactive Simulation

LOOP IN MOTION

Step through a simulated event loop iteration. Watch callbacks queue up, microtasks drain between phases, and the call stack grow and shrink in real time.

SPEED 2x
Tick: 0  |  Phase: —
CALL STACK (V8)
NEXTTICK QUEUE (priority 1)
—
PROMISE MICROTASK QUEUE (priority 2)
—
01 TIMERS
empty
02 PENDING CBS
empty
03 IDLE/PREPARE
internal
04 POLL
waiting...
05 CHECK
empty
06 CLOSE CBS
empty
MACRO QUEUE OVERVIEW
TIMER HEAP
I/O CALLBACKS
IMMEDIATES
// EXECUTION LOG

04 — Phase Deep Dives

PHASE INTERNALS

Each phase of the event loop has specific internal mechanics that affect how and when your callbacks execute. Understanding these at the C-level unlocks the ability to reason about performance pathology.

01
Timers Phase — uv__run_timers()
min-heap, threshold semantics
⌄

libuv stores all pending timers in a min-heap keyed by expiry time (uv_timer_t.timeout in milliseconds since loop start). On each loop iteration, the timers phase calls uv__run_timers(loop), which peeks at the heap root. If loop->time >= timer->timeout, the timer fires and is removed or rescheduled (for intervals).

The critical insight is that loop->time is NOT updated continuously. It is updated once per loop iteration by uv__update_time(loop), which calls uv_hrtime() (a wrapper around clock_gettime(CLOCK_MONOTONIC) on Linux). This means all timers that expire during a single poll phase wakeup will share the same loop->time value, and the timer firing order within a single "batch" depends purely on heap order.

The 1ms drift you've probably observed: Node.js applies a Math.max(1, delay) on timer registration. So setTimeout(fn, 0) becomes setTimeout(fn, 1) internally. If the event loop iteration takes longer than 1ms to start (likely on a loaded system), the timer fires. If it starts within 1ms, it doesn't. This is why setTimeout(fn, 0) vs setImmediate(fn) ordering is non-deterministic outside of I/O callbacks.

ℹ
Timer coalescing: Multiple setTimeout calls with the same delay share the same min-heap node. libuv will fire them all in the same timers phase pass, in insertion order.
deps/uv/src/timer.c (simplified)
// libuv timer heap check — simplified
void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
    heap_node = heap_min(timer_heap(loop));  // O(1) peek
    if (heap_node == NULL) break;

    handle = QUEUE_DATA(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time) break;  // Not expired yet

    uv_timer_stop(handle);       // O(log n) heap removal
    uv_timer_again(handle);      // Reschedule if interval
    handle->timer_cb(handle);   // Fire callback → back to JS
  }
}
04
Poll Phase — uv__io_poll()
epoll, blocking timeout, wakeup mechanics
⌄

The poll phase is where Node.js actually waits for I/O. It calls uv__io_poll(loop, timeout) where timeout is computed as the minimum time until the next timer expires (or 0 if there are pending callbacks, or -1 to block indefinitely).

On Linux, this translates to epoll_pwait(loop->backend_fd, events, ARRAY_SIZE(events), timeout, sigmask). The kernel will block the thread until: (1) a file descriptor becomes readable/writable/has an error, (2) the timeout expires, or (3) a signal interrupts the syscall. When events arrive, the kernel copies up to ARRAY_SIZE(events) (1024 in libuv) event structs to userspace, and libuv iterates them, invoking the registered callbacks.

On macOS, kevent with a timeout is used. On Windows, GetQueuedCompletionStatusEx pulls from the I/O Completion Port. Despite the platform differences, the semantics are identical: the main thread blocks here and only here.

🚨
Blocking the poll phase: Any synchronous CPU-intensive work in a callback blocks poll from calling back into the kernel. During this time, no I/O events can be processed — this is the source of "blocking the event loop." Profiling with --prof or clinic.js identifies these hot spots.

The poll phase also has a subtle interaction with handle reference counting. libuv maintains a count of "active" and "referenced" handles. The loop exits when there are no more referenced handles with pending I/O and no pending timers. This is why calling timer.unref() lets Node exit even if a timer is pending — it decrements the ref count.

Timeout Calculation (simplified from uv/src/unix/core.c)
static int uv__backend_timeout(uv_loop_t* loop) {
  if (loop->stop_flag != 0) return 0;
  if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
    return 0;  // Loop should exit
  if (!QUEUE_EMPTY(&loop->idle_handles)) return 0;  // Idle handles → don't block
  if (!QUEUE_EMPTY(&loop->pending_queue)) return 0;
  if (loop->closing_handles) return 0;

  return uv__next_timeout(loop);  // ms until next timer, or -1 (block forever)
}
⊕
Thread Pool — libuv work queue
UV_THREADPOOL_SIZE, work/done queue, ENOBUFS
⌄

For operations that cannot be made non-blocking by the OS (like filesystem operations — open(), stat(), read() on local disk, and dns.lookup() which calls getaddrinfo()), libuv dispatches the work to its internal thread pool via uv_queue_work().

The pool has UV_THREADPOOL_SIZE threads (default: 4, max: 1024 via env var). Work items are pushed onto a shared wq (work queue) protected by a mutex. Idle threads wait on a condition variable. When work is submitted, a thread is woken, picks up the work, executes the blocking C call (e.g. pread64()), then pushes the result onto the done queue and writes one byte to the loop's wakeup_fd — an internal pipe that libuv uses to interrupt the poll phase's epoll_wait.

This means every fs.readFile() involves: one async request allocation, one work queue push, one thread wakeup, one blocking syscall on a pool thread, one byte written to the wakeup pipe, one epoll event fired, and one callback scheduled into the poll phase. Understanding this overhead is why using streams instead of readFile for large files is significant.

THREAD POOL STATE SIMULATION
🟢
THREAD 0
idle
🔴
THREAD 1
fs.stat()
🔴
THREAD 2
dns.lookup()
🔴
THREAD 3
crypto.pbkdf2()

⚠ With only 4 threads, a 5th blocking request queues behind — causing latency spikes. Set UV_THREADPOOL_SIZE=16 for I/O-heavy servers, but watch memory: each thread has an 8MB stack.

⚡
The dns.lookup() trap: dns.resolve() uses the kernel's async resolver (c-ares) and is truly non-blocking. dns.lookup() calls getaddrinfo() which is blocking — it goes to the thread pool. Under concurrent load, all 4 threads can be consumed by DNS lookups alone, blocking all other filesystem operations. Use dns.resolve() or increase UV_THREADPOOL_SIZE when doing high-concurrency DNS.
μ
process.nextTick() — Node.js Internals
nextTickQueue, tickInfo, C++ boundary
⌄

process.nextTick() is not part of the event loop specification. It is implemented in Node.js itself via lib/internal/process/task_queues.js. The queue is drained in node::InternalCallbackScope every time Node transitions from C++ back to JavaScript — which happens at every phase boundary and after every individual callback completes.

Internally, Node maintains a tickInfo object shared between C++ and JS (via a shared ArrayBuffer). The JS side pushes to a linked list (nextTickQueue) and sets tickInfo[kHasTickScheduled] = 1. The C++ side checks this flag at every C++ → JS re-entry point and calls env->tick_callback_function() to drain the queue.

This is why process.nextTick() is described as firing "before" the next event loop phase — it fires at the boundary between the current JS frame returning to C++ and C++ re-entering JS for the next item. Conceptually it's like: current callback finishes → C++ sees kHasTickScheduled → runs all nextTick callbacks → re-checks → if empty, continues to next phase item or phase itself.

lib/internal/process/task_queues.js (simplified)
// Simplified nextTick queue drain
function processTicksAndRejections() {
  let tock;
  do {
    while (tock = queue.shift()) {
        Reflect.apply(tock.callback, tock.domain, tock.args);
        // If callback added more nextTicks, they're in queue now
    }
    // After nextTick queue empty, drain Promise microtasks
    runMicrotasks();  // V8's internal microtask checkpoint
    // If Promise handlers added nextTick, loop again
  } while (!queue.isEmpty() || hasRejections());
}

05 — Real-World Scenarios

PATHOLOGICAL PATTERNS

Understanding the event loop theoretically is one thing. Recognizing how it manifests in production bugs, latency spikes, and incorrect ordering is another.

Scenario: Starved Event Loop via synchronous CPU work
// ANTI-PATTERN: Synchronous JSON parse on large payload
app.post('/process', (req, res) => {
  const data = req.body;  // 50MB parsed synchronously by body-parser
    const result = heavyTransform(data);  // 800ms sync CPU — BLOCKS ALL I/O
  res.json(result);
});

// During the 800ms, every other request, timer, socket event is frozen.
// Node.js process.hrtime() during this period shows monotonic time advancing
// but loop->time is NOT updated — timers may fire "late" in a burst.

// FIX: Offload to worker threads
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
  app.post('/process', (req, res) => {
    const worker = new Worker(__filename, { workerData: req.body });
    worker.on('message', result => res.json(result));
  });
} else {
  // Worker thread: has its own event loop, V8 instance, and GC
  parentPort.postMessage(heavyTransform(workerData));
}
🔴
Diagnosis: Use --trace-event-categories node.async_hooks or clinic.js flame (autocannon + clinic flame your-server.js) to identify synchronous hot paths consuming wall-clock time in the main thread. Event loop lag metrics via perf_hooks.monitorEventLoopDelay() will show mean/p99 delay skyrocketing.
setTimeout(fn,0) vs setImmediate(fn) — non-deterministic outside I/O
// CASE 1: Top-level — NON-DETERMINISTIC
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Output: either "timeout, immediate" OR "immediate, timeout"
// Depends on time taken to enter first loop iteration

// CASE 2: Inside an I/O callback — ALWAYS DETERMINISTIC
fs.readFile('/etc/hosts', () => {
  setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));  // ALWAYS first
});
// Output: ALWAYS "immediate, timeout"
// WHY: I/O callback runs in poll phase. After poll completes,
// the loop goes to CHECK phase (setImmediate) BEFORE cycling
// back to TIMERS phase. Timer hasn't expired yet at this point.

// PRACTICAL RULE: Use setImmediate() when you want to execute
// "after the current I/O event but before any timers"
// Use setTimeout(fn, 0) only when semantically correct (e.g., testing)
Promise Microtask Flood — Starving I/O with recursive Promises
// ANTI-PATTERN: Unbounded recursive Promise chain
function endlessChain() {
    return Promise.resolve().then(endlessChain);  // NEVER yields to I/O
}
endlessChain();  // The event loop will never process I/O again

// The microtask queue keeps refilling itself before the loop
// can advance to the poll phase. All TCP connections queue up,
// all timers are delayed indefinitely.

// CORRECT: Yield with setImmediate for long-running iteration
async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    await processOne(items[i]);
    if (i % 100 === 0) {
            await new Promise(r => setImmediate(r));  // Yield to event loop
    }
  }
}
Thread Pool Saturation — All 4 threads blocked
// Scenario: 4 concurrent heavy bcrypt hashes + incoming fs.readFile
// Result: readFile queues behind crypto, adds 2-3s latency spike

const bcrypt = require('bcrypt');

// 4 simultaneous bcrypt operations → consumes ALL thread pool threads
[1,2,3,4].forEach(i =>
  bcrypt.hash(`pass${i}`, 12, () => console.log(`hash ${i} done`))
);

// This fs.readFile WAITS until a pool thread is free
fs.readFile('./config.json', () => console.log('config loaded'));

// SOLUTION 1: Increase pool size (before any I/O)
process.env.UV_THREADPOOL_SIZE = '16';  // Must be set BEFORE require('crypto')

// SOLUTION 2: Move bcrypt to worker threads
// SOLUTION 3: Use argon2 with worker_threads support natively
// SOLUTION 4: Implement a crypto queue with concurrency limit
const pLimit = require('p-limit');
const limit = pLimit(2);  // Max 2 concurrent hash ops, leaving threads for I/O
Handle ref() / unref() — Controlling process lifecycle
// Node exits when NO referenced handles remain active
// Understanding this is critical for graceful shutdown

const timer = setTimeout(() => {}, 1000000);
// Node would otherwise wait 1000 seconds before exiting
timer.unref();  // Decrements loop's ref count → process can exit

// Common pattern: keepalive timer that doesn't block exit
const keepAlive = setInterval(() => {
  connection.ping();
}, 30000);
keepAlive.unref();  // Ping continues but won't prevent exit

// Server handles: servers are referenced by default
// You must explicitly close them for graceful shutdown
process.on('SIGTERM', () => {
  server.close(() => {           // Stop accepting new connections
    db.disconnect(() => {        // Release db handle
      process.exit(0);           // All handles released → exits cleanly
    });
  });
});

06 — Observability

MEASURING THE LOOP

Instrumenting the event loop is essential for diagnosing latency. Node.js provides first-class APIs for this.

Event Loop Lag Monitor — production-grade instrumentation
const { monitorEventLoopDelay } = require('perf_hooks');

// High-resolution histogram — samples every 10ms using uv_timer internally
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();

setInterval(() => {
  const stats = {
    min:  histogram.min / 1e6,   // nanoseconds → ms
    max:  histogram.max / 1e6,
    mean: histogram.mean / 1e6,
        p50:  histogram.percentile(50)  / 1e6,
        p99:  histogram.percentile(99)  / 1e6,  // Most important for SLOs
    p999: histogram.percentile(999) / 1e6,
  };
  // Emit to Prometheus, Datadog, etc.
  histogram.reset();
  return stats;
}, 5000);

// Also useful: async_hooks for tracking async resource lifetimes
const ah = require('async_hooks');
const hook = ah.createHook({
  init(asyncId, type, triggerAsyncId) {
    // type: 'PROMISE', 'TCPWRAP', 'TIMERWRAP', 'FSREQCALLBACK', etc.
  },
  before(asyncId) { /* about to enter callback */ },
  after(asyncId)  { /* just left callback */ },
  destroy(asyncId){ /* resource collected */ }
});
Async Resource Type Phase Executed In Underlying Mechanism Thread Pool?
TIMERWRAPtimersuv_timer_t min-heapNo
IMMEDIATEcheckuv_check_t handleNo
PROMISEbetween phases (microtask)V8 microtask queueNo
TickObjectbetween phases (microtask)Node.js nextTickQueueNo
TCPWRAPpollepoll/kqueue on socket fdNo
FSREQCALLBACKpoll (via wakeup pipe)Thread pool → wakeup pipeYes
CRYPTOpoll (via wakeup pipe)Thread pool → wakeup pipeYes
DNSCALLBACKpoll (via wakeup pipe)Thread pool → getaddrinfoYes
GETADDRINFOREQWRAPpollc-ares (async resolver)No
SHUTDOWNWRAPpending callbacksDeferred OS shutdown ACKNo

07 — Mastery Check

TEST YOUR KNOWLEDGE

These questions separate engineers who know Node.js from those who master it.

Q1: You call setTimeout(fn, 0) and setImmediate(fn) from within a net.createServer() connection callback. What is the guaranteed execution order?
✓ Correct. Inside any I/O callback (poll phase), after the callback completes, the loop proceeds to check phase (setImmediate) before cycling back to timers. The setTimeout(fn, 0) timer can only fire in the NEXT loop iteration's timers phase — by which time setImmediate has already executed.
Q2: You have a Promise chain where each .then() schedules another .then(). Meanwhile, a pending setTimeout(fn, 1) should be ready to fire. When does the setTimeout callback execute?
✓ Correct. The microtask queue (Promise callbacks) is drained completely before the event loop can advance to any new phase. A self-replenishing Promise chain starves the timers phase entirely. This is the Promise equivalent of process.nextTick starvation — both microtask queues must be fully empty for the macrotask loop to continue.
Q3: You set UV_THREADPOOL_SIZE=4 (default). You fire 5 concurrent fs.readFile() calls simultaneously. What happens to the 5th call at the OS level?
✓ Correct. libuv's work queue is unbounded (it's a linked list). The 5th request is placed on the queue and will be picked up by the first thread that finishes. No ENOBUFS, no automatic thread spawning. This can cause cascading latency: if threads are busy with slow operations (like bcrypt or slow disk), queue depth grows and all subsequent I/O latency increases proportionally.
Q4: What does process.nextTick() registered inside another process.nextTick() do?
✓ Correct. The nextTick queue is processed in FIFO order, and new entries added during processing are appended to the same queue and drained in the same cycle. The drain loop is: while(nextTickQueue.length) { run one; }. This is why recursive nextTick can starve the event loop — the queue never becomes empty.
Q5: A Node.js process is running a single setInterval(fn, 1000) with no other code. Under what condition does the process NOT exit after the interval fires?
✓ Correct. setInterval is implemented via a uv_timer_t handle that is "active" and "referenced" by default. As long as there is at least one referenced, active handle or request, the loop continues. The process only exits when clearInterval() is called (which calls uv_timer_stop() and decrements active handle count) or when timer.unref() is called to make the handle "unreferenced".

Back to JIT-Atlas