systems · node · concurrency · performance
Process vs. thread, and where Node fits
Why isolation and shared memory are different trade-offs, and how Node's single-threaded event loop and worker_threads actually relate to that choice.
- Published
The core distinction
A process has its own isolated memory space, its own file descriptors, and its own address space, allocated and torn down by the OS. Two processes cannot read each other's memory directly. They communicate only through explicit channels the OS provides: pipes, sockets, shared memory segments, signals. Spawning a process is comparatively expensive, and crashing one does not directly corrupt another.
A thread exists inside a process and shares that process's memory space with every other thread in it. Threads are cheap to create relative to processes, and communicating between them is as fast as writing to a shared variable, which is also exactly the danger: without explicit synchronization — locks, mutexes, atomics — two threads can race on the same memory and produce a data race, an entire class of bug processes cannot have by construction, because processes do not share memory by default.
| Process | Thread | |
|---|---|---|
| Memory | Isolated | Shared with siblings |
| Creation cost | Expensive | Cheap |
| Communication | Explicit (IPC, sockets, pipes) | Implicit (shared memory) — fast but requires synchronization |
| Fault isolation | A crash stays contained | A crash can take down the whole process |
| Typical use | Isolating untrusted or unstable work | Parallelizing CPU work within one trusted unit that needs shared state |
Where the choice actually matters
Choosing process-level isolation for running untrusted or unpredictable third-party work — executing a user-submitted script, shelling out to a third-party CLI tool — is the right default specifically because a crash or an infinite loop in that code stays contained to a process that can be killed and restarted independently. The cost is real: fork/exec overhead, serialization for any data that has to cross the boundary. It is the right trade when the isolation guarantee is the point.
The opposite case, choosing threads, is right when the work is trusted, CPU-bound, and needs to share a large in-memory structure without paying a serialization cost to hand data back and forth — several threads collaboratively processing regions of the same large array, for instance.
Where Node fits
A worked example of the other side of this trade, real OS threads with bounded backpressure and coordinated shutdown, is the Rust pipeline in plane scraper.
Node's JavaScript execution is single-threaded by design. There is one thread running application code, ever, so no data races on JS objects are possible from JS code itself. Concurrency for I/O comes from the event loop plus a libuv thread pool working underneath that single JS thread: I/O operations are handed to the thread pool, and the JS thread is notified via a callback when they complete, without ever blocking on them.
A few consequences follow directly:
async/await and Promises are not parallelism. They schedule
callbacks on the single JS thread once an I/O operation completes elsewhere.
Two awaited I/O calls can be in flight concurrently. Two CPU-bound
synchronous functions cannot — the second one waits its turn on the same
thread.
A CPU-bound synchronous function blocks everything — every other request, every timer, every pending I/O callback — because there is only one JS thread to run any of it.
worker_threads is Node's actual thread primitive, for genuinely
parallelizing CPU-bound JS work, at the cost of message-passing overhead to
move data in and out, or an explicit SharedArrayBuffer if true shared
memory is needed.
child_process — spawn, fork, exec — is Node's process primitive,
for the isolation case above: running another program, or another Node
process, with a hard fault boundary.
Watching the event loop block
The claim that a CPU-bound function stops everything is worth running rather than believing. This server prints a heartbeat every hundred milliseconds and computes a deliberately naive Fibonacci number on each request:
import { createServer } from "node:http";
const started = Date.now();
setInterval(() => {
console.log(`heartbeat ${Date.now() - started}ms`);
}, 100);
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
createServer((_request, response) => {
response.end(`${fib(42)}\n`);
}).listen(3000);
Start it and the heartbeat ticks steadily. Send one request and it stops
completely until the response returns, then resumes with a gap where the
computation was. Nothing was scheduled during that window: not the timer, not
another request, not an I/O callback that had already completed. There is one
thread and fib was standing on it.
The gap is the whole lesson. An async function would not have helped, because
there is no I/O to await here, and awaiting a synchronous computation still runs
it on the same thread.
Moving the work off the loop
worker_threads is the primitive that fixes it. The worker computes and posts
the result back:
import { parentPort, workerData } from "node:worker_threads";
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData));
import { createServer } from "node:http";
import { Worker } from "node:worker_threads";
function compute(n) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./worker.js", import.meta.url), {
workerData: n,
});
worker.once("message", resolve);
worker.once("error", reject);
});
}
createServer(async (_request, response) => {
response.end(`${await compute(42)}\n`);
}).listen(3000);
Run this one and the heartbeat keeps ticking through the request. Now the
await is real: there genuinely is another thread to wait on.
Two costs come with it, and neither is hypothetical. Spawning a worker per
request is its own mistake, because thread creation is cheap relative to a
process and expensive relative to a function call; anything under sustained load
wants a pool of workers fed by a queue. And every value crossing the boundary is
structured-cloned unless it is a SharedArrayBuffer or a transferable, so moving
a large array in and out can cost more than the computation saved.
Telling which one a problem wants
Three questions settle it more reliably than instinct.
Is the work waiting, or working? Waiting on a socket, a file, or a database is I/O, and the event loop already handles it concurrently on one thread. Reaching for threads there adds overhead and solves nothing.
Do I trust the code? Untrusted or crash-prone work wants a process, because the fault boundary is the entire product being bought. A segfault in a worker thread takes the process with it.
How much data crosses the boundary? Threads win when the work is CPU-bound and the data is either small or genuinely shared. Once every call serializes a large structure both ways, the copy can cost more than the parallelism returns, and the honest answer may be to make the single-threaded version faster instead.
The browser-side analogue of the same trade-off is a Web Worker: process-like isolation, no shared memory, message-passing only, versus keeping work on the main thread.
The sharp version of this whole distinction is separating concurrency —
Node's event loop, interleaving I/O-bound work on one thread — from
parallelism — worker_threads, actually running code simultaneously across
multiple cores. Conflating the two is the most common way an explanation of
this goes wrong.