Skip to main content

What is the difference between process and thread in Node.js?

Process vs thread in Node.js - a process is an isolated OS-level program instance with its own memory heap; a thread is an execution unit inside that process that shares its heap.

Theory

TL;DR

  • Process = separate apartment with its own kitchen; thread = roommates inside that apartment sharing everything
  • Node.js runs one main JS thread per process, plus a libuv pool of 4 C++ threads for blocking I/O
  • Processes communicate via JSON over IPC (pipes); worker threads can share memory directly via SharedArrayBuffer
  • CPU-bound task: use worker_threads (Node 10.5+); need fault isolation or multi-core scale: spawn processes
  • cluster.fork() costs ~20ms (copies V8 heap); new Worker() costs ~2ms (new V8 isolate)

Quick example

javascript
// Node runs a single JS thread by default console.log('Process PID:', process.pid); // e.g., 12345 // Child process - isolated memory, separate V8 heap const { fork } = require('child_process'); const child = fork('worker.js'); // ~20ms startup child.send({ task: 'compute' }); child.on('message', result => console.log('Result:', result)); // Worker thread - same process, shared memory space const { Worker } = require('worker_threads'); const worker = new Worker('./worker.js'); // ~2ms startup worker.postMessage({ task: 'compute' });

Both options let you run code in parallel. The cost and the memory model are completely different.

Key difference

Node.js executes JavaScript in one main thread inside its OS process. The event loop lives there. All your callbacks, promises, and async/await run on that single thread. libuv offloads blocking I/O (file reads, DNS lookups) to a pool of 4 C++ threads in the background. Those threads post results back to the event loop when done. None of that pool touches your JS state.

I have seen this trip up experienced developers in production: they assume Node handles CPU work in parallel by default, then a single heavy loop hangs every incoming request. It does not work that way.

For true JS parallelism you need either worker_threads, which creates a new V8 isolate inside the same process, or child_process, which spawns a completely separate OS process with its own heap.

When to use

  • CPU-intensive work (crypto, image resize, PDF generation): worker_threads. Stays in-process, starts in ~2ms, can share memory via SharedArrayBuffer.
  • Horizontal scaling across cores: cluster module. Spawns one process per CPU, all sharing the same port.
  • Fault isolation: child_process.fork(). A crash in the child does not kill the parent.
  • I/O-heavy API server (most Express apps): stay on the main thread, let libuv handle it. No extra setup needed.
  • UV_THREADPOOL_SIZE tuning: if you hammer fs.readFile on a 16-core machine, that default of 4 threads becomes a bottleneck. Set UV_THREADPOOL_SIZE to match your core count before any I/O runs.

Comparison table

AspectProcess (child_process)Thread (worker_threads / libuv pool)
MemoryIsolated heapShared within process (via SharedArrayBuffer)
CommunicationIPC via JSON / pipespostMessage or direct shared memory
Startup cost~20ms (V8 heap copy)~2ms (new V8 isolate)
Crash impactChild crash stays isolatedUnhandled error in worker can kill the process
JS parallelismYes, true multi-coreYes (workers); libuv pool is C++ only
Use caseFault isolation, horizontal scaleCPU tasks, shared data inside one process

How libuv handles this

V8 runs your JavaScript on one thread with a single call stack. When you call fs.readFile, libuv pushes that work to its internal thread pool via uv_queue_work(). One of the 4 pool threads does the actual syscall. When it finishes, libuv signals the event loop, which picks up the callback on the next poll phase. Your JS thread never blocked.

Worker threads work differently. Node creates a new V8 isolate with its own event loop and its own call stack, but it lives inside the same OS process. That is why postMessage is the standard communication channel, and SharedArrayBuffer with Atomics is the way to share actual memory between threads without copying data.

Common mistakes

Blocking the event loop with CPU work:

javascript
// Wrong: freezes all incoming requests for 10+ seconds app.get('/hash', (req, res) => { for (let i = 0; i < 1e9; i++) {} // single JS thread, nothing else gets through res.send('done'); }); // Fix: offload to a worker thread const { Worker } = require('worker_threads'); app.get('/hash', (req, res) => { const worker = new Worker('./hash-worker.js', { workerData: req.body }); worker.on('message', result => res.send(result)); });

Sending circular or non-serializable objects via IPC:

javascript
// Wrong: child.send() serializes to JSON - circular refs throw const data = { user: req.user }; data.self = data; // circular reference child.send(data); // Error: Converting circular structure to JSON // Fix: only send plain serializable data child.send({ userId: req.user.id, action: 'compute' });

Not respawning dead cluster workers:

javascript
// Wrong: worker dies on OOM with no replacement, cluster shrinks under load cluster.fork(); // Fix: always listen for exit and respawn cluster.on('exit', (worker) => { console.log(`Worker ${worker.process.pid} died, respawning`); cluster.fork(); });

Setting UV_THREADPOOL_SIZE too high:

javascript
// Wrong: 128 threads on a 4-core machine kills perf via context switching process.env.UV_THREADPOOL_SIZE = '128'; // Fix: match your core count (set this before any require) process.env.UV_THREADPOOL_SIZE = String(require('os').cpus().length);

Real-world usage

  • PM2: defaults to one process per CPU core for zero-downtime deploys
  • NestJS microservices: uses child_process.fork() for isolated CPU modules like ML inference
  • Sharp (image processing): uses worker_threads internally for parallel resize
  • Express + cluster: standard pattern for multi-core traffic handling
  • workerpool: manages a dynamic pool of workers for tasks like PDF generation

Follow-up questions

Q: Why does fs.readFile only use 4 threads even on a 16-core machine?
A: UV_THREADPOOL_SIZE defaults to 4 regardless of CPU count. Set it to require('os').cpus().length (max 128) before any I/O code runs. It cannot be changed after the pool initializes.

Q: What is the cost difference between cluster.fork() and new Worker()?
A: Fork copies the V8 heap (~20ms, higher memory). Worker creates a new V8 isolate (~2ms, lower overhead). For short-lived parallel tasks, workers are cheaper.

Q: Can two worker threads share the same event loop?
A: No. Each worker has its own event loop. They communicate via postMessage, which is non-blocking on both ends.

Q: Why does cluster sticky mode matter for WebSocket sessions?
A: By default the cluster primary distributes connections round-robin. WebSocket sessions need to land on the same worker every time to maintain state. Sticky mode routes by client IP to make that work.

Q: Senior killer: benchmark shows 16 cores but fs.readFile only uses 4 threads. Why, and how do you fix it?
A: UV_THREADPOOL_SIZE is hardcoded to 4 in libuv and must be overridden via environment variable before Node starts. Set UV_THREADPOOL_SIZE=16 in your environment or as the very first assignment in your entry file, before any require calls. Changing it after the pool initializes has no effect.

Examples

Basic: same PID, different threadId

javascript
const { Worker, isMainThread, threadId } = require('worker_threads'); if (isMainThread) { console.log('Main thread ID:', threadId); // 0 console.log('Process PID:', process.pid); // e.g., 12345 const w = new Worker(__filename); w.on('message', msg => console.log(msg)); } else { // This runs in a worker - same PID, different threadId const { parentPort } = require('worker_threads'); parentPort.postMessage(`Worker threadId: ${threadId}, PID: ${process.pid}`); // Output: Worker threadId: 1, PID: 12345 (same process!) }

Both the main thread and the worker share the same process.pid. They live in the same OS process. Only threadId differs. That is the clearest way to see the relationship between process and thread.

Intermediate: Express server with cluster for multi-core scaling

javascript
const cluster = require('cluster'); const os = require('os'); const express = require('express'); if (cluster.isPrimary) { const numCPUs = os.cpus().length; console.log(`Primary PID: ${process.pid}, forking ${numCPUs} workers`); for (let i = 0; i < numCPUs; i++) { cluster.fork(); } // Without this, a crashed worker is never replaced cluster.on('exit', (worker) => { console.log(`Worker ${worker.process.pid} died, respawning`); cluster.fork(); }); } else { const app = express(); app.get('/', (req, res) => res.send(`Worker PID: ${process.pid}`)); app.listen(3000); console.log(`Worker ${process.pid} started`); }

Each worker is a full Node.js process with its own V8 heap. They all listen on port 3000. The OS routes incoming connections between them. One worker crashing does not take down the rest.

Advanced: shared memory between worker threads using Atomics

javascript
const { Worker, isMainThread, workerData, parentPort } = require('worker_threads'); if (isMainThread) { const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes for one Int32 const counter = new Int32Array(sharedBuffer); Atomics.store(counter, 0, 0); const worker = new Worker(__filename, { workerData: sharedBuffer }); let i = 0; const interval = setInterval(() => { Atomics.add(counter, 0, 1); // atomic increment from main thread if (++i >= 10000) clearInterval(interval); }, 0); worker.on('message', final => { console.log('Final counter:', final); // ~20000 (10000 from each side) }); } else { const counter = new Int32Array(workerData); let i = 0; const interval = setInterval(() => { Atomics.add(counter, 0, 1); // atomic increment from worker thread if (++i >= 10000) { clearInterval(interval); parentPort.postMessage(Atomics.load(counter, 0)); } }, 0); }

Without Atomics.add you would get a race condition: two threads reading and writing the same memory position at the same time, producing a final count below 20000. Atomics operations are guaranteed atomic at the CPU level. This is the only safe way to mutate shared memory across threads in Node.js.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?