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.
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.
Interprets JS → bytecode. Profiles hot paths for TurboFan JIT compilation. Manages hidden classes, inline caches.
Compiles hot bytecode → optimized machine code using speculative deoptimization. Deopt triggers re-entry to Ignition.
Generational, tri-color mark-sweep. New-space (32MB) uses Scavenger. Old-space uses concurrent incremental marking to minimize stop-the-world pauses.
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.
Executed in strict sequential order per iteration ("tick")
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.
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.
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.
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.
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.
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.
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.
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.
// 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');
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.
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.
uv__run_timers()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.
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.// 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 } }
uv__io_poll()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.
--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.
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) }
libuv work queueFor 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.
⚠ 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.
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() 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.
// 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()); }
Understanding the event loop theoretically is one thing. Recognizing how it manifests in production bugs, latency spikes, and incorrect ordering is another.
// 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)); }
--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.// 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)
// 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 } } }
// 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
// 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 }); }); });
Instrumenting the event loop is essential for diagnosing latency. Node.js provides first-class APIs for this.
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? |
|---|---|---|---|
| TIMERWRAP | timers | uv_timer_t min-heap | No |
| IMMEDIATE | check | uv_check_t handle | No |
| PROMISE | between phases (microtask) | V8 microtask queue | No |
| TickObject | between phases (microtask) | Node.js nextTickQueue | No |
| TCPWRAP | poll | epoll/kqueue on socket fd | No |
| FSREQCALLBACK | poll (via wakeup pipe) | Thread pool → wakeup pipe | Yes |
| CRYPTO | poll (via wakeup pipe) | Thread pool → wakeup pipe | Yes |
| DNSCALLBACK | poll (via wakeup pipe) | Thread pool → getaddrinfo | Yes |
| GETADDRINFOREQWRAP | poll | c-ares (async resolver) | No |
| SHUTDOWNWRAP | pending callbacks | Deferred OS shutdown ACK | No |
These questions separate engineers who know Node.js from those who master it.
process.nextTick starvation — both microtask queues must be fully empty for the macrotask loop to continue.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".