AnyLearn
All lessons
Programmingintermediate

Node.js Runtime Fundamentals

How Node 22 actually runs your code: V8, the libuv event loop and its phases, microtasks vs macrotasks, blocking pitfalls, and where worker threads, npm, pnpm, and bun fit in 2026.

Not signed in โ€” your progress and quiz score won't be saved.
Lesson progress1 / 10

What Node.js actually is

Node.js is V8 (Google's JS engine) glued to libuv (a C library for async I/O), plus a standard library written partly in JS and partly in C++. When you run node server.js, V8 parses and JITs your code, and libuv hands out a single OS thread to execute it. That single thread is the "main thread" everyone talks about.

In Node 22 LTS the JS side is fast, but it is still one thread. Anything CPU-heavy you put on it blocks the entire process: every connection, every timer, every request handler. Node's bet is that most servers are I/O-bound, not CPU-bound. When that bet holds, you get high throughput on cheap hardware. When it doesn't, you reach for worker threads or another process.

Full lesson text

All 10 steps on one page โ€” for reading, reference, and search.

Show

1. What Node.js actually is

Node.js is V8 (Google's JS engine) glued to libuv (a C library for async I/O), plus a standard library written partly in JS and partly in C++. When you run node server.js, V8 parses and JITs your code, and libuv hands out a single OS thread to execute it. That single thread is the "main thread" everyone talks about.

In Node 22 LTS the JS side is fast, but it is still one thread. Anything CPU-heavy you put on it blocks the entire process: every connection, every timer, every request handler. Node's bet is that most servers are I/O-bound, not CPU-bound. When that bet holds, you get high throughput on cheap hardware. When it doesn't, you reach for worker threads or another process.

2. The event loop in one paragraph

Your JS runs synchronously to completion. When it does I/O it hands the work to libuv (or the OS) and registers a callback. libuv spins a loop that, on each iteration (a "tick"), drains queues in a fixed order. Each completed I/O callback runs to completion before the next one starts. There is no preemption. If a callback never returns, the loop never advances.

This is why a synchronous JSON.parse of a 200 MB string stalls every other request: nothing else can run until that callback finishes.

3. Phases of one event loop tick

Each tick walks these phases in order. Microtasks (Promises, queueMicrotask) drain between every callback.

flowchart LR
  A["timers (setTimeout/setInterval)"] --> B["pending callbacks"]
  B --> C["idle, prepare (internal)"]
  C --> D["poll (I/O callbacks)"]
  D --> E["check (setImmediate)"]
  E --> F["close callbacks"]
  F --> A

4. Microtasks: process.nextTick and Promises

Two queues run BETWEEN phases, not inside them: the process.nextTick queue and the Promise microtask queue. Both drain fully before the loop moves on. process.nextTick drains first, then Promises.

This matters because a tight process.nextTick loop can starve I/O forever โ€” the poll phase never runs. Promises are similar but slightly safer (one microtask per .then). Rule of thumb: use Promises for application code; reach for process.nextTick only inside library internals where you need to defer until the current call stack unwinds but before any I/O.

5. setImmediate vs setTimeout(fn, 0)

setTimeout(fn, 0) schedules in the timers phase; setImmediate(fn) schedules in the check phase, right after poll. So if you're inside an I/O callback (poll phase), setImmediate always fires before setTimeout(fn, 0). Outside of I/O โ€” say, in your top-level script โ€” order is racy and depends on how long startup took.

const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  process.nextTick(() => console.log('nextTick'));
  Promise.resolve().then(() => console.log('promise'));
});
// nextTick, promise, immediate, timeout

Memorize that order. It comes up in interviews and in real bugs.

6. Blocking vs non-blocking I/O

Node's standard library exposes most I/O twice: a non-blocking async version (fs.readFile) and a synchronous version (fs.readFileSync). The async one returns instantly and calls you back when libuv finishes; the sync one parks the main thread until the OS returns. On a server, the sync version is almost always wrong: while it's parked, no other request can be handled.

The sneakier blockers are pure-JS: JSON.parse on huge payloads, regex with catastrophic backtracking, bcrypt.hashSync, big synchronous loops. The event loop can't help you with those. Measure with node --prof or the built-in perf_hooks.monitorEventLoopDelay if a route feels laggy under load.

7. Escape hatch: worker threads

When you genuinely need CPU parallelism โ€” image resizing, ML preprocessing, big JSON transforms โ€” use the node:worker_threads module. Each worker is a separate V8 isolate with its own event loop, communicating via postMessage (structured clone) or SharedArrayBuffer for zero-copy. They are NOT a substitute for async/await; they exist to keep CPU work off the main thread.

import { Worker } from 'node:worker_threads';
const w = new Worker('./hash-worker.js', { workerData: { rounds: 12 } });
w.on('message', (hash) => console.log(hash));

For request-level fanout, prefer a worker pool (e.g., piscina) so you're not spinning up a thread per request.

8. Package managers in 2026: npm vs pnpm vs bun

Honest state of the world: npm ships with Node and Just Works; lockfile is package-lock.json. pnpm is the choice for monorepos โ€” content-addressed store, hardlinks, fast installs, strict module resolution. bun (the runtime) ships its own package manager that is the fastest installer by a wide margin, but using bun install to feed a Node runtime is a tradeoff: occasional resolution differences vs npm/pnpm.

Pick one and stick with it per repo. For most teams in 2026: pnpm for app monorepos, npm for libraries you publish, bun if you're already running the bun runtime end-to-end. Don't mix lockfiles in the same project.

9. Diagnosing event-loop lag

When your p99 latency creeps up under load, it's usually event-loop lag. Two tools:

import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
  console.log('p99 lag ms:', (h.percentile(99) / 1e6).toFixed(1));
}, 5000);

Anything consistently over a few milliseconds means something on the main thread is starving I/O. Pair with --inspect + Chrome DevTools to capture a CPU profile and find the offender. The fix is almost never "add more threads" โ€” it's "move that one synchronous hot path off the main loop."

10. Mental model recap

Hold these four ideas and most Node behavior follows:

  1. One thread runs your JS. Anything synchronous it does blocks everything else.
  2. libuv runs the loop in fixed phases; I/O callbacks fire in poll, setImmediate in check, timers in timers.
  3. Microtasks (process.nextTick, Promises) drain between callbacks, not between phases.
  4. CPU-bound work goes to worker threads; everything else stays async on the main loop.

The rest โ€” streams, clusters, AsyncLocalStorage, native addons โ€” is built on top of this model. Get this right and the rest reads like documentation.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Inside a callback registered with fs.readFile, you schedule setImmediate(a), setTimeout(b, 0), process.nextTick(c), and Promise.resolve().then(d). What's the order?
    • c, d, a, b
    • a, b, c, d
    • d, c, b, a
    • c, d, b, a
  2. Why does a CPU-bound JSON.parse on a 100 MB string hurt a Node HTTP server?
    • It holds the main thread, so no other I/O callbacks can run until it returns.
    • It allocates on the wrong V8 heap and triggers a GC cycle.
    • libuv routes large parses to a worker thread that is single-slot by default.
    • It bypasses the poll phase and skips ahead to the check phase.
  3. What's the right tool when you need real CPU parallelism inside one Node process?
    • node:worker_threads (ideally behind a pool)
    • More setImmediate calls to spread work across ticks
    • A second event loop via libuv.spawn
    • process.nextTick scheduled in a loop
  4. Which package manager choice is most idiomatic for a 2026 multi-package monorepo running on Node 22?
    • pnpm, for its content-addressed store and strict resolution
    • npm, because it ships with Node
    • bun install paired with the Node runtime
    • yarn 1 (classic), because it's the most stable
  5. You want to deliberately avoid starving I/O. Which is the SAFER way to defer work to the next tick?
    • Promise.resolve().then(work) or queueMicrotask(work)
    • A tight loop of process.nextTick(work) calls
    • setTimeout(work, 1) repeated until done
    • Spawning a new event loop with libuv

Related lessons