You learn JavaScript in the browser, you write a few app.get('/users') handlers in Node, and the syntax looks the same. So you tell yourself this is just JavaScript with a different import. That mental model holds for about a week of real traffic.

Then a CPU-heavy handler stalls every other request. A for loop over a 200 MB JSON file pegs the process. A third-party library calls fs.readFileSync deep inside a hot path and your p99 latency triples. None of this is a JavaScript problem. It's a runtime problem, and the runtime has rules the language doesn't tell you about.

This piece is about those rules. Not the trivia version — the version that helps you predict what your service will do at 3 a.m.

Node Is A Runtime, Not A Language

JavaScript is a spec. V8 is an engine. Node.js is a runtime that wraps V8 with a C++ layer called libuv, plus a standard library written for servers: fs, net, http, crypto, worker_threads, child_process, streams, and so on. Bun and Deno are alternative runtimes for the same language.

That distinction matters because every Node-specific behavior you have to debug — the event loop, the worker pool, file descriptors, signals, process exit — lives in the runtime, not in the language. Array.prototype.map works the same everywhere. setImmediate does not exist in the browser at all. When something behaves "weirdly," the cause is almost always the runtime.

A clean test: if you can't reproduce a bug in a browser tab, it's a Node bug, and the JavaScript skill that fixes it is mostly secondary.

Single-Threaded, But Not Single-Worker

The famous line is "Node is single-threaded." That's true for your JavaScript. There is one main thread executing your code, one call stack, one event loop turn at a time. If you block it, you block everything: every request, every timer, every scheduled callback.

But under that thread, libuv runs a worker pool (default size 4) for things that can't be done non-blocking on the OS: file system operations, DNS lookups via getaddrinfo, some crypto work like pbkdf2, and zlib. Those happen off-thread, then their results come back to your code as callbacks. You can resize the pool with UV_THREADPOOL_SIZE.

TypeScript
// Looks innocent. Runs on the main thread. Blocks everything.
app.get('/report', (req, res) => {
  const rows = JSON.parse(fs.readFileSync('./big.json', 'utf8'));
  res.json(rows.filter(r => r.active));
});

The fix isn't "use async." Async file reads still hit the worker pool, and JSON.parse of a 200 MB string is pure CPU on the main thread no matter how you wrap it. The fix is to stream, paginate, or move the work to worker_threads.

I/O Is Cheap, CPU Is Expensive

The thing Node is genuinely great at is starting many slow I/O operations and waiting for them concurrently. A request that does three database calls and one HTTP fetch barely uses CPU — it spends most of its time parked on epoll/kqueue, waiting for the OS to say "your data is ready."

This is why a single Node process can handle thousands of concurrent connections on cheap hardware. It's also why CPU work is the one thing that breaks the model. Hashing a password with bcrypt, rendering a PDF, parsing a giant CSV, encrypting a payload — these don't yield to the loop, and while they run, every other request waits.

The escape hatches are real but specific: worker_threads for in-process CPU work, child_process for separate processes, the cluster module for forking the whole server, or moving the work entirely to a queue with BullMQ and a separate worker service. These are three different tools for three different problems — don't conflate them.

Diagram showing the Node.js runtime model: a single JavaScript main thread driving the libuv event loop, with the worker pool handling file I/O, DNS, and crypto on the side, while the network layer waits for OS-level I/O readiness
How Node actually runs your code

The Standard Library Is The Real Story

Most "Node is great" arguments are really arguments about the standard library and the C++ glue around it. A few APIs that quietly shape your architecture:

  • Streams (Readable, Writable, Duplex, Transform) with pipeline() for composing them. They give you backpressure for free if you use them correctly.
  • AbortController / AbortSignal — first-class everywhere now. Use them to cancel fetch, timers, file reads, and your own long-running work.
  • fetch built in since Node 18 (experimental) and stable since Node 21. No more node-fetch.
  • node:test — the native test runner shipped experimentally in Node 18 and went stable in Node 20. You don't actually need Jest for a small service anymore.
  • --watch flag for dev (Node 18+). nodemon is no longer required for most cases.
  • worker_threads / cluster / child_process — three different concurrency tools, in increasing weight. Workers share memory via SharedArrayBuffer; clusters fork the whole process; child processes are unrelated programs.

Knowing what's already in node: saves you a dependency and usually a class of bugs. Modern Node is much closer to "batteries included" than the 2016 reputation suggests.

What This Means For Code Review

When you read a Node PR, the JavaScript questions ("is this map idiomatic?") are the easy part. The runtime questions are the load-bearing ones:

  • Does this block the loop? Sync file I/O, big JSON.parse, regex with catastrophic backtracking, hashing, large for loops over arrays of objects. Anything that runs more than a few milliseconds without await is suspect.
  • Where does this fail? Network calls, DB queries, fs operations, child processes — each fails differently and needs its own error path.
  • Is cancellation wired up? A request that drops should not keep three downstream calls alive. AbortController should follow the request lifecycle.
  • Where do file descriptors go? Open streams, connections, child processes — anything you open, you have to close, especially on shutdown.
  • What happens on SIGTERM? Production Node should drain the HTTP server (server.close()), close DB pools, and let in-flight work finish before exiting.

None of that is JavaScript. It's all runtime behavior, and it's what separates a Node service that runs from a Node service that runs well.

A One-Sentence Mental Model

Node.js is one JavaScript thread sitting on top of an OS-aware C++ scheduler — write code that lets the scheduler do its job, and the runtime will keep up with you; write code that blocks the thread, and no amount of clever JavaScript will save you.