Skip to content

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.

ProcessThread
MemoryIsolatedShared with siblings
Creation costExpensiveCheap
CommunicationExplicit (IPC, sockets, pipes)Implicit (shared memory) — fast but requires synchronization
Fault isolationA crash stays containedA crash can take down the whole process
Typical useIsolating untrusted or unstable workParallelizing 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.

Related notes

  • Database indexing and query diagnosis

    How B-tree, hash, composite, covering, and partial indexes differ, why column order decides whether an index is used, and how to diagnose a high P95.

Contact

Available now. Permanent senior or staff platform roles, in Munich or remote within the EU.

Email is the fastest way to reach me.