Node.js NotesRohit’s interview study guide
Chapter 11

Scaling: Workers, Processes & Clusters

Getting past one thread — worker threads for CPU work, child processes, the cluster module, and what changes on Kubernetes.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Strategies for CPU-intensive work

Node is excellent at waiting: one thread can keep thousands of database queries, HTTP calls and file reads in flight, because the waiting happens in the OS and libuv, not in JavaScript. It is bad at computing on that same thread. A report that takes 8 seconds of JavaScript on the main thread delays every other request in that process for 8 seconds — including a trivial /health check, which makes your orchestrator think the process is dead.

The mental model: the event loop is a single cashier. Customers who need to "wait for the kitchen" (I/O) step aside and the cashier serves the next person. A customer who wants the cashier to personally count 10 million coins (CPU work) holds the whole queue. The fix is never to make the cashier "async"; it's to send the coin-counting somewhere else.

The first trap is believing async or a Promise moves work off the thread. It doesn't:

async-still-blocks.cjs
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }

async function fibAsync(n) { return fib(n); } // "async" changes the return type, not the thread

const t0 = Date.now();
setTimeout(() => console.log(`timer due at 10 ms fired at ${Date.now() - t0} ms`), 10);

fibAsync(38).then((v) => console.log(`fib(38) = ${v} resolved at ${Date.now() - t0} ms`));
console.log(`fibAsync() returned at ${Date.now() - t0} ms`);

// fibAsync() returned at 197 ms
// fib(38) = 39088169 resolved at 198 ms
// timer due at 10 ms fired at 198 ms
What’s happening
  1. A 10 ms timer is scheduled first. If the loop were free, it would fire at about 10 ms.
  2. fibAsync(38) is called. An async function runs its body synchronously until its first await — and there is no await, so the whole recursive fib(38) (about 39 million calls) runs right now, on the main thread.
  3. fibAsync() only returns (an already-resolved promise) after ~197 ms. Even the next line, console.log, couldn't run before then.
  4. The .then callback is a microtask, so it runs as soon as the script finishes (198 ms), before the event loop gets to timers. The timer finally fires at 198 ms instead of 10 ms — 188 ms late, exactly how long the computation held the thread.
  5. Wrapping work in new Promise(...) has the same problem: the executor runs synchronously. Async syntax decides when you get the result, not which thread computes it.
AdvancedChoosing a strategy for CPU work

From my notes, the answer to "How would you handle CPU-intensive tasks in a Node.js application?" lists five techniques: worker threads, offloading to an external service, the cluster module, child processes, and streaming large datasets. They're all real, but they solve different problems:

The work is…UseWhy
Waiting on I/O (DB, HTTP, files)normal async APIsalready concurrent; nothing to offload
Heavy JavaScript (parsing, image math, reports)a bounded worker poolruns JS on other cores, main thread stays responsive
An external program (FFmpeg, ImageMagick, Python)a child processit isn't JavaScript; it needs its own process anyway
Long or must-survive-a-restart jobsa queue + separate worker servicedurable, retryable, scaled independently
Too much total traffic for one processmore processes: cluster or more podsscales the whole server, not one request
Huge data (a 20 GB CSV)streamsbounds memory — but per-chunk CPU still blocks

My notes put it well for cluster: it "increases throughput but doesn't solve the problem of individual requests being CPU-bound." One heavy request still blocks the process it lands on; cluster just means there are other processes to take the rest of the traffic (see Cluster vs worker threads).

Two more nuances:

  • Some native APIs already offload. crypto.pbkdf2, crypto.scrypt, zlib.gzip and async fs run on libuv's thread pool; wrapping them in a worker gains nothing. Their *Sync versions do block (see Avoiding synchronous work on hot paths).
  • Streams bound memory, not CPU. Streaming a 20 GB CSV keeps memory flat, but if each chunk needs heavy parsing, that parsing still runs on the main thread. Combine streaming with a worker pool when you need both.

For work that takes minutes, or must survive a deploy, don't hold an HTTP request open at all. Accept the job, persist it, and let a separate service do it:

Output
POST /reports  ->  validate, store job, enqueue  ->  202 Accepted { jobId }
                                  |
                       worker service picks it up (retries on failure)
                                  |
                       writes result + status to DB / object storage
                                  |
GET /jobs/:id  ->  { status: "done", url: "…" }   (or a webhook / websocket notification)
AdvancedExample: a video-upload pipeline

A video-upload pipeline combines every option: stream the upload straight to object storage (bounded memory), enqueue a processing job (request latency independent of processing time), run an independently scaled worker service that spawns FFmpeg as a child process (specialised native CPU work), then store the thumbnails and update the job status.

AdvancedCorrecting my notes: when workers became stable

#Worker threads

A worker thread runs JavaScript on another OS thread inside the same process. Each worker gets its own V8 isolate — its own heap, its own globals, its own module cache and its own event loop. It's like hiring a second cook who works in a separate kitchen in the same building: they don't share counters (variables), they pass dishes through a hatch (postMessage), and they can work at the same time as you on another CPU core.

The example from my notes, using one file for both sides:

worker-basic.cjs
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (msg) => console.log(msg));
} else {
  parentPort.postMessage('Worker thread message');
}
// Worker thread message
What’s happening
  1. The file runs first on the main thread, where isMainThread is true. new Worker(__filename) starts a new thread that loads this same file from the top, in a fresh isolate.
  2. In the worker, isMainThread is false, so it takes the else branch. parentPort is the worker's end of the message channel back to its parent.
  3. parentPort.postMessage('Worker thread message') sends a copy of the string to the parent. The parent's 'message' listener receives it on its own event loop and logs it.
  4. After posting, the worker has nothing left to do (no listeners keeping its loop alive), so it exits. The main thread's listener isn't a reason to stay alive either, so the process exits too.
AdvancedFull worker: workerData, errors and exit

A more complete version passes input with workerData and handles all three outcomes — a result, an error and an exit:

worker-demo.cjs
const { Worker, isMainThread, parentPort, workerData, threadId } = require('node:worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename, { workerData: { limit: 100_000 } });
  let received = false;

  worker.once('message', (result) => {
    received = true;
    console.log('Result:', result);
  });
  worker.once('error', (err) => console.error('Worker failed:', err));
  worker.once('exit', (code) => {
    if (code !== 0 || !received) console.error('No successful result; exit code:', code);
    else console.log('Worker exited with code', code);
  });

  console.log(`Main thread (threadId ${threadId}, pid ${process.pid}) can continue`);
} else {
  let total = 0;
  for (let i = 0; i < workerData.limit; i++) total += i;
  console.log(`worker running as threadId ${threadId}, pid ${process.pid}`);
  parentPort.postMessage(total);
}

// Main thread (threadId 0, pid 76661) can continue
// worker running as threadId 1, pid 76661
// Result: 4999950000
// Worker exited with code 0
What’s happening
  1. new Worker(__filename, { workerData }) starts the thread and clones { limit: 100_000 } into it. Constructing the worker returns immediately — the thread boots in the background (about 10 ms here).
  2. So the main thread logs first: threadId 0 (the main thread is always 0) and its PID. It then returns to its event loop, free to handle other work.
  3. In the worker, workerData.limit is 100000. It sums 0…99,999 = 4,999,950,000. Its console.log shows threadId 1 but the same PID — a worker is a thread, not a process. (A worker's console.log is forwarded to the main thread asynchronously, which is why it appears in the main thread's output stream.)
  4. parentPort.postMessage(total) sends the number; the parent's 'message' handler logs Result: 4999950000. Then the worker's loop is empty, it exits with code 0, and the 'exit' handler confirms success.
  5. Why all three listeners: 'message' is the success path, 'error' fires if the worker throws an uncaught exception (the worker then exits), and 'exit' always fires last — checking received there catches a worker that exited without answering. Without an 'error' listener, an exception in the worker becomes an uncaught error in the main thread.
AdvancedReference: the worker API

The API in one place:

  • workerData — initial input, cloned once at start-up.
  • worker.postMessage(msg) / parentPort.on('message') — repeated messages after start-up. A long-lived worker listens on parentPort for jobs.
  • 'message', 'error', 'exit' (and 'online') events on the Worker.
  • worker.terminate() — stops the worker as soon as possible and returns a promise for its exit code.
  • resourceLimits: { maxOldGenerationSizeMb } — a heap limit just for that worker.
  • In ES modules, point at a separate file with new Worker(new URL('./worker.mjs', import.meta.url)). A plain relative string is resolved against the process's current working directory, not your file, which breaks when the app is started from another folder.

A worker that never returns doesn't hurt the main thread — the old chat in my notes got this wrong:

stuck-worker.cjs
const { Worker } = require('node:worker_threads');

// The worker's code is passed as a string with eval: true, to keep the demo in one file.
const worker = new Worker('while (true) {}', { eval: true });
worker.on('exit', (code) => console.log('exit event, code', code));

const t0 = Date.now();
let ticks = 0;
const ticker = setInterval(() => ticks++, 100);

setTimeout(async () => {
  console.log(`main thread ticked ${ticks} times in ${Date.now() - t0} ms while the worker spins`);
  clearInterval(ticker);
  const code = await worker.terminate();
  console.log('terminate() resolved with', code);
}, 1000);

// main thread ticked 9 times in 1001 ms while the worker spins
// exit event, code 1
// terminate() resolved with 1
What’s happening
  1. The worker runs while (true) {} — it will never finish and never check its own event loop.
  2. Meanwhile the main thread's 100 ms interval keeps firing: 9 ticks in 1,001 ms (the 10th would be due at the same moment as the 1 s timer). The infinite loop is on another core, so the main thread is unaffected.
  3. At 1 s, worker.terminate() stops the worker's JavaScript even though it's mid-loop — V8 can interrupt a running isolate — and the promise resolves with exit code 1. The 'exit' event fires with the same code.
  4. Without terminate(), the spinning worker would keep the process alive forever and use 100% of one core. Pools use a per-job timeout plus terminate() (and a replacement worker) to recover from this.
AdvancedCorrecting my notes: infinite loops in workers
AdvancedCapping worker memory with resourceLimits

A worker can at least be stopped from eating all the memory:

oom-worker.cjs
const { Worker } = require('node:worker_threads');

// A worker that allocates forever, capped at a 32 MB old generation.
const worker = new Worker('const a = []; while (true) a.push(new Array(100_000).fill(1));', {
  eval: true,
  resourceLimits: { maxOldGenerationSizeMb: 32 },
});
worker.on('error', (err) => console.log('error:', err.code, '-', err.message));
worker.on('exit', (code) => console.log('worker exit code', code));
setTimeout(() => console.log('main thread still alive, heapUsed',
  (process.memoryUsage().heapUsed / 2 ** 20).toFixed(1), 'MB'), 1000);

// error: ERR_WORKER_OUT_OF_MEMORY - Worker terminated due to reaching memory limit: JS heap out of memory
// worker exit code 1
// main thread still alive, heapUsed 4.3 MB
What’s happening
  1. The worker pushes 800 KB arrays forever. Its heap would grow until the whole process ran out of memory.
  2. resourceLimits.maxOldGenerationSizeMb: 32 gives that one isolate its own heap limit. When it's hit, Node terminates just that worker and emits 'error' with code ERR_WORKER_OUT_OF_MEMORY, then 'exit' with code 1.
  3. The main thread is untouched — still running, with a 4.3 MB heap — because each isolate has a separate heap. This is the kind of fault isolation workers do give you: per-isolate JS heap and JS exceptions.

#A worker pool

Creating a worker costs roughly 10–15 ms and ~10 MB of memory (measured below), so starting one per request is wasteful and, worse, unbounded: 500 concurrent requests would start 500 threads fighting over a handful of cores. The standard design is a pool: a fixed number of long-lived workers and a queue of jobs. A job waits in the queue until a worker is free; each worker handles one job at a time.

Think of a taxi rank: a fixed number of taxis (workers), a queue of passengers (jobs), and a dispatcher that gives the next passenger to the next free taxi. Adding more taxis than there are lanes on the road (CPU cores) only creates traffic.

The task file — the code each worker runs:

primes-task.cjs
const { parentPort } = require('node:worker_threads');

// Deliberately CPU-heavy: trial division for every n below `limit`.
function countPrimes(limit) {
  let count = 0;
  for (let n = 2; n < limit; n++) {
    let prime = true;
    for (let d = 2; d * d <= n; d++) if (n % d === 0) { prime = false; break; }
    if (prime) count++;
  }
  return count;
}

// Inside a worker: answer every message. On the main thread parentPort is null, so this is skipped.
parentPort?.on('message', (limit) => parentPort.postMessage(countPrimes(limit)));
module.exports = { countPrimes };
AdvancedMeasured: main thread vs a pool of 4

The pool, plus a demo that runs 8 jobs either on the main thread or in a pool of 4, with a 100 ms timer ticking the whole time:

pool.cjs
const { Worker } = require('node:worker_threads');
const path = require('node:path');

class WorkerPool {
  #file; #size; #maxQueue;
  #workers = new Set(); #idle = []; #queue = [];
  #closed = false;

  constructor(file, size, { maxQueue = 100 } = {}) {
    this.#file = file; this.#size = size; this.#maxQueue = maxQueue;
    for (let i = 0; i < size; i++) this.#spawn();
  }

  run(data) {
    return new Promise((resolve, reject) => {
      if (this.#closed) return reject(new Error('pool closed'));
      if (this.#queue.length >= this.#maxQueue) return reject(new Error('pool overloaded'));
      this.#queue.push({ data, resolve, reject });
      this.#dispatch();
    });
  }

  #spawn() {
    const worker = new Worker(this.#file);
    worker.job = null;
    worker.on('message', (result) => {
      const { resolve } = worker.job;
      worker.job = null;
      this.#idle.push(worker);
      resolve(result);
      this.#dispatch();
    });
    worker.on('error', (err) => worker.job?.reject(err)); // uncaught error in the worker
    worker.on('exit', () => {                              // crashed or terminated
      this.#workers.delete(worker);
      this.#idle = this.#idle.filter((w) => w !== worker);
      if (!this.#closed) { this.#spawn(); this.#dispatch(); } // keep the pool at full size
    });
    this.#workers.add(worker);
    this.#idle.push(worker);
  }

  #dispatch() {
    while (this.#idle.length && this.#queue.length) {
      const worker = this.#idle.pop();
      worker.job = this.#queue.shift();
      worker.postMessage(worker.job.data);
    }
  }

  async close() {
    this.#closed = true;
    await Promise.all([...this.#workers].map((w) => w.terminate()));
  }
}

module.exports = { WorkerPool };

if (require.main === module) {
  const { countPrimes } = require('./primes-task.cjs');
  const jobs = Array(8).fill(2_000_000);                 // 8 jobs, ~175 ms each
  const onMain = process.argv[2] === 'main';

  const t0 = Date.now();
  const ticks = [];
  const ticker = setInterval(() => ticks.push(Date.now() - t0), 100);

  (async () => {
    let results;
    if (onMain) {
      await new Promise((r) => setTimeout(r, 0));        // start on a later turn, like a handler
      results = jobs.map(countPrimes);
    } else {
      const pool = new WorkerPool(path.join(__dirname, 'primes-task.cjs'), 4);
      results = await Promise.all(jobs.map((n) => pool.run(n)));
      await pool.close();
    }
    clearInterval(ticker);
    console.log(onMain ? 'main thread:' : 'pool of 4:', `${results.length} x ${results[0]} primes, done at ${Date.now() - t0} ms`);
    console.log(`  ticks: [${ticks.join(', ')}]`);
  })();
}

// $ node pool.cjs main
// main thread: 8 x 148933 primes, done at 1372 ms
//   ticks: []
// $ node pool.cjs
// pool of 4: 8 x 148933 primes, done at 376 ms
//   ticks: [101, 202, 303]
What’s happening
  1. Main-thread run: the 8 jobs (~175 ms each) run back to back on the event loop thread: done at 1,372 ms, and the 100 ms timer recorded zero ticks. For 1.4 seconds the process couldn't run a single other callback — in a server, every request would have stalled.
  2. Pool run, setup: the constructor starts 4 workers and puts them in #idle. pool.run(n) 8 times pushes 8 jobs onto #queue, each with the resolve/reject of the promise it returned. #dispatch() pairs idle workers with queued jobs: 4 jobs go out immediately, 4 wait.
  3. When a worker answers, its 'message' handler resolves that job's promise (stored on worker.job, which is how a reply is matched to its job), puts the worker back in #idle, and calls #dispatch() again, which hands it the next queued job. So the 8 jobs run in two waves of 4 on four cores: done at 376 ms, about 3.6× faster.
  4. Most importantly, the timer kept firing on schedule — 101, 202, 303 ms. The main thread only did the cheap parts (posting messages, resolving promises), so it stayed free to serve other requests the whole time.
  5. Failure handling: an exception in a worker rejects its current job via 'error'; the worker then exits, and 'exit' removes it and spawns a replacement so the pool stays at 4. I tested a task that throws for negative input: [2, 'negative', 6, 8, 10] — one job failed, the rest succeeded on the replacement.
  6. Backpressure: maxQueue makes run() reject with 'pool overloaded' when 100 jobs are already waiting. Without a cap, a traffic spike would queue unlimited jobs (and their data) in memory; rejecting early lets the route return 503 instead of timing out.
AdvancedSizing the pool

How big should the pool be? Start from the CPUs actually available to the process — os.availableParallelism() — and leave one core for the main thread. More workers than cores doesn't add throughput; the OS just time-slices them, adding context switches and memory. Measure with your real workload.

AdvancedRecommendation: maintained pool libraries

#Sharing data with workers: clone, transfer, share

Workers have separate heaps, so an ordinary JavaScript object can never be in two isolates at once. When you postMessage something, there are exactly three things that can happen to it:

  • Clone (the default) — the value is serialised with the structured clone algorithm and rebuilt in the receiver. The receiver gets an independent copy. Supports plain objects, arrays, Date, Map, Set, typed arrays, Errors and more; not functions, and class instances arrive as plain objects.
  • Transfer — for ArrayBuffers (and MessagePorts), you can list the object in a transfer list. The underlying memory moves to the receiver without copying, and the sender's ArrayBuffer is detached (length 0). Like handing over a parcel: fast, but you no longer have it.
  • Share — a SharedArrayBuffer is the same block of memory visible in both threads. Writes by one are seen by the other. Like a whiteboard in the corridor: everyone sees it, so you need rules (Atomics) to avoid writing over each other.

The browser-side basics are on the JavaScript site at Transferables; here they all are in Node:

share.cjs
const { Worker } = require('node:worker_threads');

// One worker that edits whatever it receives and reports what it saw.
const worker = new Worker(`
  const { parentPort } = require('node:worker_threads');
  parentPort.on('message', (msg) => {
    if (msg.kind === 'clone') { msg.user.name = 'changed in worker'; parentPort.postMessage(msg.user.name); }
    if (msg.kind === 'transfer') parentPort.postMessage('worker got ' + msg.buf.byteLength + ' bytes');
    if (msg.kind === 'share') { new Int32Array(msg.sab)[0] = 42; parentPort.postMessage('worker wrote 42'); }
  });
`, { eval: true });
const reply = () => new Promise((r) => worker.once('message', r));

(async () => {
  // 1. Clone (the default): the worker gets a copy
  const user = { name: 'Rohit', joined: new Date(0), tags: new Set(['admin']) };
  worker.postMessage({ kind: 'clone', user });
  console.log('clone:   ', await reply(), '| main still has', user.name);

  // 2. Transfer: ownership of the bytes moves; the sender's ArrayBuffer is detached
  const buf = new ArrayBuffer(1024 * 1024);
  const view = new Uint8Array(buf);
  console.log('transfer: before', buf.byteLength, 'bytes, view length', view.length);
  worker.postMessage({ kind: 'transfer', buf }, [buf]);
  console.log('transfer: after ', buf.byteLength, 'bytes, view length', view.length);
  console.log('transfer: ', await reply());

  // 3. Share: both threads see the same memory
  const sab = new SharedArrayBuffer(4);
  worker.postMessage({ kind: 'share', sab });
  console.log('share:   ', await reply(), '| main reads', new Int32Array(sab)[0]);

  // Not everything can be cloned
  try { worker.postMessage({ kind: 'clone', user: { fn() {} } }); }
  catch (e) { console.log('function: ', e.name, '-', e.message); }

  await worker.terminate();
})();

// clone:    changed in worker | main still has Rohit
// transfer: before 1048576 bytes, view length 1048576
// transfer: after  0 bytes, view length 0
// transfer:  worker got 1048576 bytes
// share:    worker wrote 42 | main reads 42
// function:  DataCloneError - fn() {} could not be cloned.
What’s happening
  1. Clone: user (with a Date and a Set, both cloneable) is serialised and rebuilt in the worker. The worker sets name on its copy and reports changed in worker, but the main thread's user.name is still Rohit. Cloning costs time proportional to the data size, and you pay it on every message.
  2. Transfer: postMessage(msg, [buf]) lists buf in the transfer list. Before: 1048576 bytes. Immediately after the call — before the worker has even received it — buf.byteLength is 0 and the Uint8Array view's length is 0 too: every view over a transferred buffer loses its storage. The worker reports 1048576 bytes. No copy was made; the memory changed owner.
  3. Touching a detached buffer doesn't crash; reads through the view just see an empty array. If the main thread still needs the data, clone it instead or send a copy (buf.slice(0)).
  4. Share: a SharedArrayBuffer is not transferred or copied: both sides get a handle to the same 4 bytes. The worker writes 42 through its own Int32Array, and the main thread reads 42 through its own. Only the raw bytes are shared — the two JS heaps remain separate.
  5. Not cloneable: an object with a method throws DataCloneError synchronously from postMessage. Class instances are cloned as plain objects — I sent a new Point() and it arrived with constructor.name === 'Object' and no methods. Send plain data and rebuild objects on the other side.
AdvancedData races with SharedArrayBuffer and Atomics

Shared memory brings back the problem single-threaded JavaScript never had — data races:

atomics.cjs
const { Worker } = require('node:worker_threads');

const code = `
  const { workerData: { sab, atomic } } = require('node:worker_threads');
  const counter = new Int32Array(sab);
  for (let i = 0; i < 1_000_000; i++) {
    if (atomic) Atomics.add(counter, 0, 1); // one indivisible read-modify-write
    else counter[0]++;                       // read, add, write: three steps that can interleave
  }
`;

async function run(atomic) {
  const sab = new SharedArrayBuffer(4);
  const workers = Array.from({ length: 4 }, () => new Worker(code, { eval: true, workerData: { sab, atomic } }));
  await Promise.all(workers.map((w) => new Promise((r) => w.on('exit', r))));
  console.log(`${atomic ? 'Atomics.add' : 'counter[0]++'}: ${new Int32Array(sab)[0].toLocaleString()} (expected 4,000,000)`);
}

run(false).then(() => run(true));

// counter[0]++: 1,267,700 (expected 4,000,000)
// Atomics.add: 4,000,000 (expected 4,000,000)
What’s happening
  1. Four workers each increment the same shared Int32Array slot one million times, so the right answer is 4,000,000. workerData carries the SharedArrayBuffer, which is shared rather than cloned.
  2. counter[0]++ is really three steps: read the value, add 1, write it back. When two threads read 100 at the same moment, both write 101 and one increment is lost. Over 4 million increments, roughly two-thirds were lost: 1,267,700 this run, and a different number every run (1,399,750 and 1,317,087 in two others).
  3. Atomics.add(counter, 0, 1) does the read-add-write as one indivisible CPU operation, so no update can be lost: exactly 4,000,000 every time.
  4. Atomics also has load/store/compareExchange and wait/notify (block a worker until another signals). Use shared memory only when copying is genuinely too slow — large numeric buffers or high-rate coordination — because every access now needs this discipline.
AdvancedEdge case: pooled Buffers and transfer

#Child processes: spawn, exec, execFile and fork

A child process is a whole separate operating-system process started by your Node process. It has its own PID and its own memory, and it doesn't need to be Node at all — it can be ffmpeg, git, python or a shell. From my notes: child processes "run independently from the parent process and have their own memory space," and are created with spawn(), exec() or fork().

The four functions differ in two ways: is there a shell? and how does output come back?

FunctionRunsShellOutputUse for
spawn(cmd, args)any executableno (unless shell: true)streams (stdout, stderr)long-running tools, large output
execFile(file, args, cb)any executablenocollected, given to the callbackshort commands with small output
exec(command, cb)a command stringyes (/bin/sh -c, cmd.exe)collected, given to the callbacktrusted shell one-liners with pipes
fork(modulePath)another Node scriptnostdio + an IPC channel (send/'message')Node-to-Node jobs

spawn starts the process and gives you its stdio as streams, so you see output as it's produced:

spawn-demo.cjs
const { spawn } = require('node:child_process');

const t0 = Date.now();
const child = spawn(process.execPath, [
  '-e',
  "for (let i = 1; i <= 3; i++) setTimeout(() => console.log('line', i, 'from pid', process.pid), i * 100); console.error('progress: starting');",
]);
console.log('parent pid', process.pid, 'spawned child pid', child.pid);

child.stdout.on('data', (chunk) => process.stdout.write(`[stdout +${Date.now() - t0} ms] ${chunk}`));
child.stderr.on('data', (chunk) => process.stdout.write(`[stderr] ${chunk}`));
child.on('error', (err) => console.error('Could not start:', err));
child.on('exit', (code, signal) => console.log('exit:', code, signal));
child.on('close', (code) => console.log('close:', code));

// parent pid 78494 spawned child pid 78495
// [stderr] progress: starting
// [stdout +124 ms] line 1 from pid 78495
// [stdout +224 ms] line 2 from pid 78495
// [stdout +324 ms] line 3 from pid 78495
// exit: 0 null
// close: 0
What’s happening
  1. spawn(process.execPath, [...]) starts a new Node process (process.execPath is the absolute path of the running node, so the demo doesn't depend on PATH). The arguments are passed as an array, straight to the program — no shell parses them. child.pid is available immediately: 78495, a different process from the parent's 78494.
  2. The child writes progress: starting to stderr first. It arrives on child.stderr — a reminder that stderr isn't only for errors; many tools write progress there.
  3. The three stdout lines arrive 100 ms apart (+124, +224, +324 ms), as the child prints them. That's the point of spawn: output is streamed, so you can process gigabytes of output or show live progress without buffering it all.
  4. 'exit' fires when the process ends (code 0, no signal). 'close' fires after that, once its stdio streams have also closed — so wait for 'close' if you need all the output. 'error' fires only if the process couldn't be started at all (e.g. ENOENT for a missing executable).
  5. If you pipe output, consume it: if nobody reads stdout, the OS pipe buffer fills up and the child blocks on its next write.
Advancedexec and execFile buffer the output

exec and execFile collect the output for you:

exec-demo.cjs
const { exec, execFile } = require('node:child_process');
const node = process.execPath;

// exec: ONE string, parsed by a shell (/bin/sh -c on Unix). Output is collected.
exec(`"${node}" -e "console.log(6 * 7)" && echo "shell ran this too"`, (err, stdout, stderr) => {
  console.log('exec stdout:', JSON.stringify(stdout));
});

// execFile: executable + argument array, no shell. Output is collected.
execFile(node, ['-e', 'console.log(process.argv.slice(1))', '; echo INJECTED'], (err, stdout) => {
  console.log('execFile stdout:', stdout.trim());
});

// Collected output is capped by maxBuffer (default 1 MiB); past it the child is killed.
execFile(node, ['-e', "process.stdout.write('x'.repeat(2_000_000))"], (err, stdout) => {
  console.log('maxBuffer:', err.code, '| got', stdout.length, 'chars');
});

// execFile stdout: [ '; echo INJECTED' ]
// exec stdout: "42\nshell ran this too\n"
// maxBuffer: ERR_CHILD_PROCESS_STDIO_MAXBUFFER | got 1048576 chars
// (the three callbacks finish in a different order on different runs)
What’s happening
  1. exec hands the whole string to /bin/sh -c. The shell interprets &&, quotes and echo, so the output is both commands' output: "42\nshell ran this too\n". Shell features are the convenience — and the danger: if any part of that string came from a user, they could add ; rm -rf ~.
  2. execFile runs the executable directly with an argument array. The third argument '; echo INJECTED' reaches the program as one literal string — process.argv.slice(1) prints [ '; echo INJECTED' ] — and nothing is executed, because there's no shell to interpret ;. This is why execFile/spawn with an args array is the safe default (see Command injection and child processes).
  3. maxBuffer: collected output is held in memory, capped by maxBuffer (1 MiB by default). The child wrote 2,000,000 characters, so Node killed it and called back with ERR_CHILD_PROCESS_STDIO_MAXBUFFER, keeping the first 1,048,576. For large output, use spawn and stream it.
  4. All three run concurrently, so their callbacks arrive in whatever order the processes finish — it differed between my runs. Don't rely on it.
  5. execSync, execFileSync and spawnSync exist too; they block the event loop until the child exits. Fine in a build script, harmful in a request handler.
Advancedfork: a child with an IPC channel

fork starts another Node script with an IPC channel, so parent and child exchange messages as JavaScript values:

child.cjs
// Runs as a separate Node process with an IPC channel to its parent.
process.on('message', (job) => {
  process.send({ id: job.id, result: job.value * 2, pid: process.pid });
});
Advancedparent.cjs: messaging the forked child
parent.cjs
const { fork } = require('node:child_process');
const path = require('node:path');

const child = fork(path.join(__dirname, 'child.cjs'));
console.log('parent pid', process.pid, '-> child pid', child.pid);

child.once('message', (message) => {
  console.log('reply:', message);
  child.disconnect(); // close the IPC channel; the child has nothing left to do, so it exits
});
child.on('exit', (code) => console.log('child exited with', code));
child.on('error', console.error);

child.send({ id: 1, value: 21 }); // serialised and copied to the other process

// parent pid 78796 -> child pid 78797
// reply: { id: 1, result: 42, pid: 78797 }
// child exited with 0
What’s happening
  1. fork(path) is a spawn of process.execPath with the script path, plus an extra IPC channel. The path is built from __dirname so it works no matter which directory the app was started from.
  2. child.send({ id: 1, value: 21 }) serialises the object (JSON-like by default) and writes it into the channel. In the child, process.on('message') receives a new object with the same data.
  3. The child replies with process.send(...): { id: 1, result: 42, pid: 78797 }. The id is how a parent with many jobs in flight matches replies to requests.
  4. As long as the IPC channel is open, the child's event loop stays alive (it's waiting for messages). child.disconnect() closes the channel, the child has nothing left to do, and it exits with code 0.
  5. Despite the name, Node's fork() is not Unix fork(): it doesn't copy the parent's memory or running state. The child starts a fresh Node runtime and runs the script from the top — which is why its memory is completely separate.
AdvancedCorrecting my notes: exec output type

#Worker threads vs child processes

Both let you do work outside the main event loop. The difference is the isolation boundary: a worker is a thread inside your process; a child is a separate process. Everything else follows from that.

Worker threads one process · one PID main thread own isolate · heap · loop worker thread own isolate · heap · loop postMessage clone / transfer shared mem a native crash takes down all threads Child process / fork parent · PID 100 own memory own Node runtime child · PID 101 separate memory any program (fork = Node + IPC) IPC / stdio serialised messages Cluster primary process owns :8000 hands out connections worker worker worker own PID own PID own PID own heap own heap own heap
Workers share a process and can share memory; child processes and cluster workers are separate processes that only exchange messages.

The cost difference is measurable:

startup.cjs
const { Worker } = require('node:worker_threads');
const { fork } = require('node:child_process');
const { performance } = require('node:perf_hooks');
const path = require('node:path');

function timeWorker() {
  const t0 = performance.now();
  const w = new Worker("require('node:worker_threads').parentPort.postMessage({ pid: process.pid })", { eval: true });
  return new Promise((r) => w.once('message', (msg) => {
    const ms = performance.now() - t0;
    w.terminate().then(() => r({ ms, ...msg }));
  }));
}

function timeFork() {
  const t0 = performance.now();
  const c = fork(path.join(__dirname, 'hello-child.cjs')); // sends { pid, rssMB } and exits
  return new Promise((r) => c.once('message', (msg) => {
    const ms = performance.now() - t0;
    c.disconnect();
    r({ ms, ...msg });
  }));
}

(async () => {
  console.log('main pid', process.pid);
  for (const [label, fn] of [['worker', timeWorker], ['fork  ', timeFork]]) {
    const runs = [];
    for (let i = 0; i < 5; i++) runs.push(await fn());
    const pids = [...new Set(runs.map((r) => r.pid))].join(', ');
    console.log(`${label} first reply after ${runs.map((r) => r.ms.toFixed(0)).join(', ')} ms | pids: ${pids}`);
  }
})();

// hello-child.cjs is one line:
//   process.send({ pid: process.pid, rssMB: Math.round(process.memoryUsage().rss / 2 ** 20) });
//
// main pid 79083
// worker first reply after 16, 14, 14, 13, 14 ms | pids: 79083
// fork   first reply after 30, 32, 34, 31, 31 ms | pids: 79084, 79085, 79086, 79087, 79088
What’s happening
  1. Each function starts a fresh worker or child, waits for its first message, and records the time. Five of each, sequentially.
  2. Workers answered in ~13–16 ms, and every one reports the main process's PID (79083): they're threads inside it. Booting a new V8 isolate and Node environment is the main cost.
  3. Forked children took ~30 ms — roughly twice as long — because the OS has to create a whole process and Node has to start from scratch (load the binary, initialise V8, bootstrap Node). Each has a new PID.
  4. Memory follows the same pattern: each forked child reported ~44 MB RSS on its own, while ten idle workers grew the parent's RSS by about 10 MB each. (Exact numbers vary by machine and Node version; the ratio is what matters.)
  5. Both costs are why you reuse workers or children through a pool instead of starting one per job.
AdvancedReference: worker threads vs child processes
Worker threadChild process (fork/spawn)
Boundarythread in the same process (same PID)separate OS process (new PID)
Memoryown JS heap; can share SharedArrayBuffer, transfer ArrayBuffercompletely separate; nothing shared
CommunicationpostMessage (structured clone, transfer)IPC (serialised) or stdio streams
Start-up and memory costlower (~14 ms, ~10 MB here)higher (~30 ms, ~44 MB here)
RunsJavaScript onlyany program
Fault isolationJS errors and heap limits per worker; native crashes kill the processa crash, leak or process.exit stays in the child
Best forCPU-heavy JavaScriptexternal tools, untrusted or crash-prone work, a separate runtime

From my notes' "Important Difference": workers share the process, child processes don't. The practical rule is: CPU-heavy JavaScript → worker threads; another program, or work you want fully isolated → child process.

#The cluster module

From my notes: the cluster module creates multiple Node.js processes (workers) that share the same server port. Node runs JavaScript on one thread, so to use all the cores of a machine you fork the process once per core; each worker handles requests independently, and the primary acts as a load balancer distributing incoming connections.

Picture a restaurant with one front door (the port) and several identical kitchens (processes). A host at the door (the primary) sends each new party (TCP connection) to the next kitchen. Each kitchen has its own staff, ingredients and order book — they share nothing except the door.

Here's the code from my notes, corrected for current Node:

cluster-notes.cjs
const cluster = require('node:cluster');
const http = require('node:http');
const numCPUs = require('node:os').availableParallelism(); // notes: os.cpus().length

if (cluster.isPrimary) {                                   // notes: cluster.isMaster (deprecated)
  console.log(`Primary ${process.pid} is running`);
  for (let i = 0; i < numCPUs; i++) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died (${signal || code})`);
    // In production you'd usually fork a replacement here (with a back-off to avoid crash loops).
  });
} else {
  // Workers can share any TCP connection, like HTTP
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello world\n');
  }).listen(8000);
  console.log(`Worker ${process.pid} started`);
}
AdvancedSeeing the cluster distribution

To see the distribution, here's a runnable version that listens on a random port, sends requests from the primary, and shuts down cleanly:

cluster-demo.cjs
const cluster = require('node:cluster');
const http = require('node:http');

if (cluster.isPrimary) {
  console.log(`primary ${process.pid}, scheduling: ${cluster.schedulingPolicy === cluster.SCHED_RR ? 'round-robin' : 'OS'}`);
  for (let i = 0; i < 4; i++) cluster.fork();

  let listening = 0;
  cluster.on('listening', async (worker, address) => {
    if (++listening < 4) return;               // wait until all four workers are listening
    const port = address.port;

    const get = (agent) => new Promise((resolve, reject) => {
      http.get({ port, path: '/', agent }, (res) => {
        let body = '';
        res.on('data', (c) => (body += c)).on('end', () => resolve(body));
      }).on('error', reject);
    });

    const fresh = [];
    for (let i = 0; i < 8; i++) fresh.push(await get(false));          // a new TCP connection each time
    console.log('new connection per request:', fresh.join(' '));

    const keepAlive = new http.Agent({ keepAlive: true, maxSockets: 1 });
    const reused = [];
    for (let i = 0; i < 8; i++) reused.push(await get(keepAlive));     // one reused connection
    console.log('one keep-alive connection: ', reused.join(' '));
    keepAlive.destroy();

    for (const w of Object.values(cluster.workers)) w.disconnect();    // graceful: close servers, then exit
  });

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} exited (code ${code}${signal ? ', ' + signal : ''})`);
  });
} else {
  let hits = 0; // per-process state: every worker has its own copy
  http.createServer((req, res) => {
    hits++;
    res.end(`${process.pid}#${hits}`);
  }).listen(0); // in a cluster, port 0 gives every worker the SAME random port
}

// primary 79265, scheduling: round-robin
// new connection per request: 79266#1 79268#1 79267#1 79269#1 79266#2 79268#2 79267#2 79269#2
// one keep-alive connection:  79266#3 79266#4 79266#5 79266#6 79266#7 79266#8 79266#9 79266#10
// worker 79267 exited (code 0)
// worker 79269 exited (code 0)
// worker 79266 exited (code 0)
// worker 79268 exited (code 0)
What’s happening
  1. The script runs first as the primary (cluster.isPrimary is true). cluster.fork() four times starts four new Node processes running the same file; in them isPrimary is false, so they take the else branch. The scheduling policy is round-robin, the default on every platform except Windows.
  2. Each worker calls listen(0). A worker doesn't really bind the port itself: it asks the primary, which owns the listening socket. With port 0 the primary picks one random port and gives the same port to all four. When the fourth worker's 'listening' event arrives, the primary knows the port and starts sending requests.
  3. New connection per request (agent: false): the primary accepts each TCP connection and hands it to the next worker in turn — 79266, 79268, 79267, 79269, then the same cycle again. Each body shows that worker's own hits count: #1 on the first lap, #2 on the second. The hits variable exists separately in each process; nothing is shared.
  4. One keep-alive connection: all eight requests go to 79266, whose count climbs #3 … #10. Cluster balances connections, not requests. Once a connection belongs to a worker, every request on it goes to that worker. With long-lived keep-alive connections (or one busy client), load can be very uneven.
  5. worker.disconnect() tells each worker to close its server (finishing in-flight connections) and then exit; all four exit with code 0 and the primary, with nothing left, exits too. I checked afterwards with ps: no stray processes.
AdvancedWhat cluster gives you

What cluster gives you: N event loops, N heaps, one port. That's more total throughput (up to the number of cores) and resilience — if one worker crashes, the others keep serving while the primary forks a replacement. What it doesn't give you: shared memory between workers (in-memory sessions, caches, rate limiters and counters are per process — see Shared state and the singleton pattern), or any speed-up for a single heavy request.

AdvancedCorrecting my notes: isMaster and load balancing

#Cluster vs worker threads

My notes have the rule many people learn: "Cluster module for I/O-bound tasks and worker threads for CPU-bound". It's a useful first approximation and wrong in an important way. I/O-bound work doesn't need cluster at all — one process already handles thousands of concurrent I/O operations, because the waiting happens outside JavaScript. And cluster doesn't solve CPU-bound work; it just gives the other requests somewhere else to go.

The better framing is what you're scaling:

  • Cluster (or more pods) scales the whole server: N copies, each with its own event loop. Use it when one process's total CPU is the limit — whether that CPU comes from JSON, templating, TLS, or business logic.
  • Worker threads move specific heavy tasks off the event loop inside one server, so that one heavy request doesn't stall the light ones.

The analogy from my notes, made concrete: cluster is opening more identical restaurants; worker threads are hiring prep cooks inside one restaurant for the slow dishes. More restaurants serve more customers, but a customer who orders a 30-minute dish still blocks their own restaurant's only cook — unless that restaurant has a prep cook.

Here's that blocking, measured — two cluster workers, one 1-second CPU-heavy request, and four quick ones right behind it:

cluster-block.cjs
const cluster = require('node:cluster');
const http = require('node:http');

if (cluster.isPrimary) {
  for (let i = 0; i < 2; i++) cluster.fork();
  let ready = 0;
  cluster.on('listening', async (w, { port }) => {
    if (++ready < 2) return;
    const t0 = Date.now();
    const get = (path) => new Promise((resolve) => {
      http.get({ port, path, agent: false }, (res) => {
        let body = '';
        res.on('data', (c) => (body += c)).on('end', () => resolve(`${path} -> ${body} after ${Date.now() - t0} ms`));
      });
    });
    const slow = get('/slow');                 // lands on one worker and blocks it for ~1 s
    await new Promise((r) => setTimeout(r, 50));
    const fast = [];
    for (let i = 0; i < 4; i++) { fast.push(get('/fast')); await new Promise((r) => setTimeout(r, 10)); }
    for (const line of await Promise.all([slow, ...fast])) console.log(line);
    for (const w of Object.values(cluster.workers)) w.disconnect();
  });
} else {
  http.createServer((req, res) => {
    if (req.url === '/slow') { const end = Date.now() + 1000; while (Date.now() < end); }
    res.end(`pid ${process.pid}`);
  }).listen(0);
}

// /slow -> pid 79435 after 1010 ms
// /fast -> pid 79436 after 61 ms
// /fast -> pid 79435 after 1011 ms
// /fast -> pid 79436 after 81 ms
// /fast -> pid 79436 after 93 ms
What’s happening
  1. /slow goes to worker 79435, which busy-waits for a second. That worker's event loop is now completely stuck.
  2. The first /fast goes to the other worker, 79436, and returns in 61 ms (it was sent 50 ms after /slow). Cluster helped: this request didn't care about the slow one.
  3. The second /fast is round-robined to 79435 — the blocked worker. It returns after 1,011 ms, just after /slow finishes. A trivial request took a second purely because of where it landed.
  4. The third and fourth /fast went to 79436 and returned quickly. The primary only hands a worker its next connection after the worker has acknowledged the previous one, and the blocked worker couldn't acknowledge — so in this run it received just the one extra connection. With more traffic, or keep-alive connections already pinned to it, many more requests would be stuck behind it.
  5. The fix for /slow is not more cluster workers; it's moving that second of computation into a worker pool inside each process. Then each process's event loop stays free, and cluster (or pods) adds capacity on top.
AdvancedReference: cluster vs worker threads
ClusterWorker threads
Unitprocesses (one per core)threads inside one process
Sharesthe listening portmemory (optionally), the process
Solvestotal throughput across cores, crash resilienceone heavy task blocking the event loop
Doesn't solvea CPU-heavy request blocking its workerneeding more total server capacity
Statenothing shared — use Redis/DBmessage passing; per-isolate module state
AdvancedCorrecting my notes: the I/O vs CPU rule

#Do you need cluster on Kubernetes?

My notes ask: "Is cluster really needed when we deploy our application to Kubernetes?" Usually, no. Kubernetes already does what cluster does, one level up: it runs N replicas of your container, load-balances traffic across them through a Service, restarts any that crash, and can add replicas automatically when CPU rises (the Horizontal Pod Autoscaler). One Node process per pod, with a CPU request/limit of about one core, is the common, simplest setup.

Running cluster inside a pod adds a second layer doing the same job, and it has real costs:

  • Health checks get blurry. Kubernetes probes the pod, not each worker. If one of four cluster workers is stuck, the pod still looks healthy, and a quarter of its traffic hangs.
  • Resource accounting is harder. The pod's memory limit is shared by all processes, so one leaking worker can get the whole pod OOM-killed. Each Node process also needs its own heap limit set (--max-old-space-size) to fit inside the pod's limit.
  • CPU limits are easy to get wrong. Cluster sizes itself from the core count it sees, which may be the node's 64 cores rather than the pod's 2-CPU quota — 64 workers fighting over 2 CPUs.
  • Shutdown is more complex. On SIGTERM the primary has to drain and stop every worker before the grace period ends (Graceful shutdown).

When cluster (or PM2's cluster mode) still makes sense: on VMs or bare metal without an orchestrator; when you deliberately run large pods (say 4 CPUs) to reduce per-pod overhead; or when start-up is expensive and you want several processes to share one container image's warm-up. It's an architecture choice, not a rule.

What stays useful inside each pod: a worker pool for CPU-heavy tasks, so the pod's one event loop stays responsive to requests and health probes.

parallelism.cjs
const os = require('node:os');
console.log('os.cpus().length         :', os.cpus().length);
console.log('os.availableParallelism():', os.availableParallelism());
// os.cpus().length         : 18
// os.availableParallelism(): 18
What’s happening
  1. On this laptop both report 18, because nothing restricts the process.
  2. availableParallelism() reflects restrictions the OS reports to the process (such as CPU affinity on Linux), which is why Node recommends it over cpus().length for sizing pools.
  3. A Kubernetes CPU limit is enforced by throttling (a CPU-time quota), not by hiding cores. cpus().length never reflects it, and whether availableParallelism() does depends on the libuv version and cgroup setup — so check what your container actually reports, and prefer configuring pool or cluster sizes explicitly (e.g. from an environment variable set alongside the pod's CPU limit).

#Scaling to thousands of concurrent requests

From my notes, "How to Scale a Node.js Application to Handle Thousands of Concurrent Requests": horizontal scaling (cluster to use multiple cores), load balancing, stateless application design (marked important — "if it's stateless then it will be easier to scale"), and worker threads for CPU-bound tasks. The first thing to realise is that "thousands of concurrent requests" is easy for one Node process if they're mostly waiting, and impossible if they're mostly computing:

thousand.cjs
const http = require('node:http');
const { createHistogram, performance } = require('node:perf_hooks');

const mode = process.argv[2]; // 'io' or 'cpu'
const server = http.createServer((req, res) => {
  if (mode === 'io') {
    setTimeout(() => res.end('ok'), 100);                      // waiting on "the database": no CPU used
  } else {
    const end = performance.now() + 2; while (performance.now() < end); // 2 ms of CPU per request
    res.end('ok');
  }
});

server.listen(0, async () => {
  const { port } = server.address();
  const agent = new http.Agent({ keepAlive: true, maxSockets: 1_000 }); // up to 1,000 connections, kept open and reused
  const latency = createHistogram();
  const get = () => new Promise((resolve, reject) => {
    const t0 = performance.now();
    http.get({ port, agent }, (res) => res.resume().on('end', () => {
      latency.record(Math.round(performance.now() - t0));
      resolve();
    })).on('error', reject);
  });

  // open the 1,000 connections first so connection setup isn't part of the measurement
  await Promise.all(Array.from({ length: 1_000 }, get));
  latency.reset();

  const t0 = performance.now();
  await Promise.all(Array.from({ length: 1_000 }, get));         // 1,000 requests at once
  console.log(`${mode}: total ${(performance.now() - t0).toFixed(0)} ms | ` +
    `latency p50 ${latency.percentile(50)} ms, p99 ${latency.percentile(99)} ms`);
  agent.destroy();
  server.close();
});

// $ node thousand.cjs io
// io: total 177 ms | latency p50 128 ms, p99 173 ms
// $ node thousand.cjs cpu
// cpu: total 2341 ms | latency p50 1081 ms, p99 2311 ms
What’s happening
  1. One process runs both a server and a client. The client opens 1,000 keep-alive connections (a warm-up round, so TCP setup isn't measured), then fires 1,000 requests at once and records each request's latency.
  2. io mode: each request "waits for the database" with a 100 ms timer. The handler returns immediately after scheduling it, so all 1,000 requests are waiting at the same time: the whole batch finishes in 177 ms, and the median request takes 128 ms — barely more than the 100 ms wait itself. One thread, one process, 1,000 concurrent requests.
  3. cpu mode: each request needs 2 ms of CPU. 1,000 × 2 ms = 2 seconds of computing that one thread must do one after another. The batch takes 2.3–2.7 s, the median request waits ~1.08 s and the unluckiest ~2.3 s — not because any request is slow, but because they queue for the single thread.
  4. So the scaling question is always "how much CPU per request?" At 2 ms per request, one process tops out around 500 requests/second no matter how async the code is. Beyond that you need more processes (cluster or pods), less CPU per request (profile it), or the heavy part in a worker pool.
  5. (My numbers include the client running in the same process; a real load test uses a separate machine with a tool like autocannon, k6 or wrk. The contrast is the point.)
AdvancedStateless instances for horizontal scaling

Once one process is efficient, you scale horizontally: many identical processes behind a load balancer. That only works if any instance can serve any request — statelessness. The hits counter in the cluster demo showed what happens otherwise: every process has its own copy. If user sessions, carts, rate-limit counters or caches live in process memory, the next request may land on a different instance (or a freshly deployed one) and find nothing.

Output
stateful:   user -> pod A (session in memory)   ... redeploy/scale ...   user -> pod B: "please log in again"
stateless:  user -> any pod -> session/cart/limits in Redis or the DB, identity in a token/cookie
AdvancedChecklist: thousands of concurrent requests

The checklist for "thousands of concurrent requests":

  • Keep the event loop free — async I/O everywhere, no sync calls in handlers, CPU-heavy work in a worker pool. Monitor event-loop delay.
  • Stateless instances — sessions, caches and counters in a shared store (Redis, the DB); identity in a token or session cookie.
  • Scale horizontally — cluster on a VM, replicas on Kubernetes, behind a load balancer that health-checks each instance.
  • Mind the shared resources — 20 pods × a pool of 10 DB connections = 200 connections; make sure the database can take it. Concurrency moves the bottleneck downstream.
  • Bound everything — timeouts on outgoing calls, limits on body size and concurrency, rate limits and load-shedding (503) when overloaded, so a spike degrades gracefully instead of cascading.
  • Cache and offload — cache repeatable reads, put slow work on a queue.

#HTTP/2

From my notes: HTTP/2 offers significant improvements over HTTP/1.1 — multiplexing (multiple requests over the same TCP connection), header compression, and server push (sending assets before the client asks).

The core idea is multiplexing. HTTP/1.1 can reuse a connection with keep-alive, but responses on one connection come back in order, one at a time — so browsers open ~6 connections per origin to get parallelism. HTTP/2 splits one connection into many independent streams, and sends their data as interleaved binary frames (HEADERS, DATA), each tagged with its stream ID. A slow response no longer holds up the ones behind it. Headers are compressed with HPACK, which remembers headers already sent on the connection, so repeated cookies and user-agents cost a few bytes instead of hundreds.

Think of HTTP/1.1 on one connection as a single-lane road: cars arrive in the order they left. HTTP/2 is the same road with many lanes painted on: each car (response) moves at its own speed.

Here are three requests — slowest first — on one HTTP/1.1 keep-alive connection, then on one HTTP/2 connection:

h1.cjs
const http = require('node:http');

const server = http.createServer((req, res) => {
  const delay = Number(new URL(req.url, 'http://x').searchParams.get('delay'));
  setTimeout(() => res.end(`${req.url} done`), delay);
});

server.listen(0, () => {
  const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); // one connection, like one h2 session
  const t0 = Date.now();
  const get = (path) => new Promise((resolve) => {
    http.get({ port: server.address().port, path, agent }, (res) => {
      let body = '';
      res.on('data', (c) => (body += c)).on('end', () => { console.log(`+${Date.now() - t0} ms  ${body}`); resolve(); });
    });
  });
  Promise.all([get('/report?delay=300'), get('/profile?delay=100'), get('/logo?delay=10')]).then(() => {
    agent.destroy();
    server.close();
  });
});

// +314 ms  /report?delay=300 done
// +416 ms  /profile?delay=100 done
// +429 ms  /logo?delay=10 done
Advancedh2.cjs: a minimal HTTP/2 server
h2.cjs
const http2 = require('node:http2');

// h2c: HTTP/2 without TLS. Fine for a local demo and service-to-service; browsers only speak HTTP/2 over TLS.
const server = http2.createServer();
server.on('stream', (stream, headers) => {
  const delay = Number(new URL(headers[':path'], 'http://x').searchParams.get('delay'));
  setTimeout(() => {
    stream.respond({ ':status': 200, 'content-type': 'text/plain' });
    stream.end(`${headers[':path']} served on stream ${stream.id}`);
  }, delay);
});

server.listen(0, () => {
  const client = http2.connect(`http://localhost:${server.address().port}`);
  let sockets = 0;
  server.on('connection', () => sockets++);

  const t0 = Date.now();
  const get = (path) => new Promise((resolve) => {
    const req = client.request({ ':path': path });
    let body = '';
    req.setEncoding('utf8');
    req.on('data', (c) => (body += c));
    req.on('end', () => { console.log(`+${Date.now() - t0} ms  ${body}`); resolve(); });
    req.end();
  });

  // Three requests at once, slowest first, on ONE connection
  Promise.all([get('/report?delay=300'), get('/profile?delay=100'), get('/logo?delay=10')]).then(() => {
    console.log('TCP connections used:', sockets);
    client.close();
    server.close();
  });
});

// +16 ms  /logo?delay=10 served on stream 5
// +106 ms  /profile?delay=100 served on stream 3
// +306 ms  /report?delay=300 served on stream 1
// TCP connections used: 1
What’s happening
  1. HTTP/1.1, one connection (maxSockets: 1): the agent can't send the second request until the first response has finished, so they run one after another: 300 ms, then +100, then +10 — the 10 ms logo arrives at 429 ms. That's application-level head-of-line blocking; browsers work around it by opening more connections.
  2. HTTP/2, one connection: http2.connect opens a single session, and client.request() three times opens three streams on it. Client-initiated streams get odd IDs — 1, 3, 5 — in the order they were opened.
  3. The server's 'stream' event fires for each one. Pseudo-headers like :path and :status replace HTTP/1's request line and status line. Each stream responds when its own timer fires.
  4. So the responses arrive in order of readiness, not request order: logo at 16 ms, profile at 106 ms, report at 306 ms — all over one TCP connection, with the total no longer the sum of the delays.
  5. http2.createServer() is cleartext HTTP/2 ("h2c"), handy for local demos and internal services. Browsers only use HTTP/2 over TLS, which needs http2.createSecureServer({ key, cert }) as in my notes; add allowHTTP1: true to serve old clients from the same port.
AdvancedWhat HTTP/2 doesn't fix

What HTTP/2 does not fix:

  • Slow application code. A 2-second query is still 2 seconds, and a CPU-heavy handler still blocks its event loop. HTTP/2 improves transport, not your server's work.
  • TCP head-of-line blocking. All streams share one TCP connection, so a lost packet stalls every stream until it's retransmitted. HTTP/3 runs over QUIC (on UDP), which gives each stream independent delivery.

In production, HTTP/2 is very often terminated at the edge — a CDN, load balancer, ingress or NGINX speaks HTTP/2 (or HTTP/3) with browsers and forwards plain HTTP/1.1 to Node. Then your Express app needs no node:http2 code at all; Express itself is built on the http module's request/response API. Native node:http2 is worth it when Node talks to clients directly, or for internal HTTP/2 services such as gRPC.

AdvancedCorrecting my notes: server push
Where does the request go?

A cluster has 4 workers using the default round-robin policy. A single client opens one keep-alive connection and sends 100 requests on it, one after another. How many workers handle those requests?

Show answer

One. All 100 requests are handled by the same worker.

What’s happening
  1. Cluster's primary owns the listening socket and distributes connections as they're accepted — round-robin picks a worker per new TCP connection.
  2. Once that connection has been handed to a worker, the primary is out of the picture: the worker reads and answers every request that arrives on that socket.
  3. A keep-alive connection carries all 100 requests, so they all land on the worker that got the connection — exactly what the cluster demo showed (79266#3 … 79266#10).
  4. This is why load can be uneven with long-lived connections (keep-alive agents, HTTP/2 sessions, WebSockets), and why load balancers in front of Node often balance per request or cap connection lifetime.
What does the main thread print?
JavaScript
const { Worker } = require('node:worker_threads');
const buf = new ArrayBuffer(8);
const w = new Worker('require("node:worker_threads").parentPort.once("message", () => {})', { eval: true });
w.postMessage(buf);
console.log(buf.byteLength);
w.postMessage(buf, [buf]);
console.log(buf.byteLength);
w.terminate();
Show answer

It prints 8, then 0.

What’s happening
  1. The first postMessage(buf) has no transfer list, so the buffer is cloned: the worker gets its own 8-byte copy and the main thread's buf is untouched — byteLength is still 8.
  2. The second call lists buf in the transfer list [buf]. The underlying memory moves to the worker, and the main thread's buf is detached immediately, during the postMessage call itself.
  3. So the second console.log prints 0 — the same thing the sharing demo showed with a 1 MB buffer. Any typed-array view over buf would now have length 0 too.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.