open atlas
↑ Back to track
Node.js, zero to senior NODE · 01 · 04

Runtime bootstrap and the event loop

Node runs your JS on one thread driven by libuv's phase-ordered event loop; microtasks (nextTick > Promise) drain between callbacks, fs/crypto use a 4-slot threadpool, and any sync work blocks every concurrent request.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Your service has a p50 of 3ms and looks idle in the dashboard — CPU at 20%, memory flat, no GC pressure. Then p99 jumps to 800ms under no extra traffic, and the spikes correlate with login bursts. You add more pods; the cliff stays exactly where it was. The smoking gun isn’t the network and isn’t the database: it’s a synchronous bcrypt hash and a default libuv threadpool of four slots. One thread runs all your JavaScript, and you’d been quietly serializing every request behind a queue you didn’t know existed. The fix wasn’t more machines — it was understanding what runs your code between node app.js and the first line.

From node app.js to your first line

Nothing of yours runs first. When you type node app.js, the C++ node binary boots an entire runtime before your code exists. It creates a V8 isolate (an isolated instance of the JS engine with its own heap and GC) and a context (the global object and built-ins for that isolate). It hands V8 a platform shim so V8 can post tasks back to libuv, the C library that owns the event loop and the OS I/O primitives. libuv then initializes the default loop. Next, Node runs its internal JavaScript bootstrap — thousands of lines that wire up process, the globals (console, Buffer, timers), and the module loaders (CommonJS require and the ESM loader). Only then is your entry module loaded and its top-level synchronous code executed, top to bottom, to completion.

That last clause is the whole mental model: your top-level code runs synchronously to the end before a single asynchronous callback fires. A setTimeout(fn, 0) at line 1 does not run at line 1 — it registers a timer and keeps going. The loop is entered only when the call stack is empty, i.e. after your module finishes. This is why a long synchronous block at startup delays every timer and I/O callback you scheduled: they are all waiting for the stack to drain.

console.log("1: top-level start");
setTimeout(() => console.log("4: timer"), 0);
Promise.resolve().then(() => console.log("3: promise microtask"));
console.log("2: top-level end");

// stdout:
// 1: top-level start
// 2: top-level end          ← all sync code finishes first
// 3: promise microtask      ← microtasks drain before the loop ticks
// 4: timer                  ← loop's timers phase, last

The libuv loop: phases in a fixed order

Once the stack is empty, Node enters the loop. libuv does not have one undifferentiated queue — it has an ordered set of phases, and each tick walks them in the same sequence, draining that phase’s own callback queue before moving on:

  1. timers — callbacks for setTimeout/setInterval whose threshold has elapsed.
  2. pending callbacks — a few deferred system callbacks (e.g. some TCP error codes).
  3. idle / prepare — internal bookkeeping; you never touch these.
  4. poll — the heart of the loop. It computes how long to block, then blocks waiting for I/O (new connections, socket data, completed fs work), running their callbacks as they arrive.
  5. checksetImmediate callbacks fire here, right after poll.
  6. close callbackssocket.on("close") and friends.

The poll phase is where the process spends idle time: with nothing else to do, it sleeps in epoll_wait (Linux) / kqueue (macOS) until the kernel says a file descriptor is ready. That kernel-level readiness notification — not the threadpool — is how Node scales tens of thousands of concurrent network sockets on one thread.

Why this works

Why is setImmediate(fn) (check phase) sometimes before and sometimes after setTimeout(fn, 0) (timers phase) at the top level? Because setTimeout(fn, 0) is clamped to a 1ms minimum, and whether that 1ms has elapsed by the time the loop reaches the timers phase depends on machine speed and what else loaded — so the order is non-deterministic. But inside an I/O callback the order flips and is guaranteed: you are already past the timers phase, so the next phase reached is poll → check, meaning setImmediate always fires before the next loop’s setTimeout. That determinism is exactly why setImmediate is the correct tool for “run after the current I/O, before more timers.”

Microtasks cut between everything: nextTick > Promise > the loop

The phases are the macro structure. Underneath them sit two microtask queues that Node drains between every callback and between phase transitions — they are not phases of the loop at all. The order is fixed and worth memorizing:

  1. The process.nextTick queue (Node-specific, highest priority).
  2. The Promise / queueMicrotask job queue (the ECMAScript microtask queue).

Both are fully drained to empty before the loop is allowed to advance. So the global priority is: process.nextTick > Promise/queueMicrotask > setTimeout/setImmediate/I-O. A program makes this concrete:

console.log("sync");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
queueMicrotask(() => console.log("queueMicrotask"));
console.log("sync end");

// stdout (deterministic for the microtasks; timeout/immediate order may swap at top level):
// sync
// sync end
// nextTick          ← nextTick queue drains first
// promise           ← then the Promise job queue…
// queueMicrotask    ← …in FIFO order with promise jobs
// timeout           ← only now does the loop tick its phases
// immediate

process.nextTick runs sooner than a Promise — useful when an API must defer to “after the current operation” without yielding to the loop at all (e.g. emitting an event the caller hasn’t had a chance to subscribe to yet). That same priority is its danger, covered below.

The threadpool: where “single-threaded” stops being true

JS executes on one thread, but several built-in operations cannot be done with a non-blocking syscall, so libuv offloads them to a threadpool: fs.* (file I/O), dns.lookup (the default resolver, which calls blocking getaddrinfo), CPU-bound crypto like pbkdf2/scrypt/randomBytes, and zlib compression. The pool’s default size is 4 (UV_THREADPOOL_SIZE, settable up to 1024, and only read once at startup). Crucially, network I/O does not use the pool — sockets go through epoll/kqueue in the poll phase. So your 10,000 idle sockets cost nothing, but five concurrent fs.readFiles with a pool of four means the fifth waits for a slot.

Pick the best fit

A request handler must hash a password (CPU-bound, ~150ms) on every login. Logins arrive in bursts. How do you run the hash so the loop keeps serving other requests under load?

Quiz

Why can a recursive process.nextTick() hang a server that handles network requests, while a recursive setImmediate() does not?

Recall before you leave
  1. 01
    A teammate says 'Node is single-threaded, so we never have concurrency bugs and one slow request can't affect others.' Where is each half of that sentence right and wrong?
  2. 02
    What keeps a Node process alive, what makes it exit, and how does .unref() fit in?
Recap

Between node app.js and your first line, the binary builds a V8 isolate and context, libuv initializes the event loop, and Node’s internal JS bootstrap wires up process, the globals, and the module loaders — only then is your entry module loaded and its top-level synchronous code run to completion. The loop is entered only when the call stack is empty, and libuv ticks a fixed sequence of phases — timers → pending → poll (which blocks waiting for I/O via epoll/kqueue) → check (setImmediate) → close — draining each phase’s own queue. Cutting across all of it are the microtask queues, emptied between every callback and phase: process.nextTick first, then Promise/queueMicrotask jobs, so the global priority is nextTick > Promise > timers/immediate/I-O. The single JS thread is helped by a libuv threadpool of default size 4 (UV_THREADPOOL_SIZE, max 1024) for fs, dns.lookup, CPU-crypto, and zlib — but never for network sockets. The two failure modes that define senior intuition: blocking the loop (one synchronous 200ms hash or fs.readFileSync freezes all ~100 in-flight requests behind it) and threadpool exhaustion (the 5th concurrent crypto/fs op with pool size 4 queues, a cliff that masquerades as a network fault) — plus recursive process.nextTick, which starves the poll phase outright because its queue drains before the loop can ever advance. Watch event-loop lag (p99 > ~50–100ms means trouble), keep the JS thread free, and let an empty loop exit the process while ref’d handles keep it alive and .unref() lets it go.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.