10LOC
Node.jsadvanced

child_process to fan work out across CPU cores

Published February 19, 2027

import { fork } from "node:child_process";
import { cpus } from "node:os";
import { once } from "node:events";
import { fileURLToPath } from "node:url";

const countPrimes = (start: number, end: number) => {
  let count = 0;
  for (let n = Math.max(start, 2); n <= end; n++) {
    let prime = true;
    for (let d = 2; d * d <= n; d++) if (n % d === 0) prime = false;
    if (prime) count++;
  }
  return count;
};

if (typeof process.send === "function") {
  process.on("message", ({ start, end }: { start: number; end: number }) => {
    process.send!({ count: countPrimes(start, end) });
  });
} else {
  const RANGE_END = 2_000_000;
  const chunk = Math.ceil(RANGE_END / cpus().length);

  const run = async () => {
    const results = await Promise.all(
      cpus().map(async (_cpu, i) => {
        const worker = fork(fileURLToPath(import.meta.url));
        worker.send({ start: i * chunk + 1, end: Math.min((i + 1) * chunk, RANGE_END) });
        const [{ count }] = await once(worker, "message");
        worker.disconnect();
        return count as number;
      }),
    );
    console.log(`${results.reduce((a, b) => a + b, 0)} primes up to ${RANGE_END}`);
  };

  run();
}

What

Run as the parent, this file forks one child process per CPU core and hands each a slice of the range 1..2,000,000, asking it to count primes in that slice. Run as a fork of itself, it does the counting for whatever range it's given and reports the count back. The parent sums the results.

Why it matters

os.cpus().length cores sitting idle while one core grinds through a CPU-bound loop is the same problem worker_threads solves — but child_process solves it with full OS-level isolation instead of shared-memory threads. Each forked process gets its own V8 instance, its own memory space, and a crash in one child can't corrupt another's state or take down the parent. That isolation costs more to spin up than a thread does, which is exactly the trade-off: pick child_process when the work is heavy enough, or risky enough, to be worth a real process boundary; pick worker_threads when spin-up cost or shared-ArrayBuffer transfer matters more.

This snippet keeps both the parent's orchestration logic and the child's worker logic in one file — genuinely necessary here, not padding. A correct fan-out needs to spawn workers, hand each one a distinct chunk of work, wait for every result over IPC, and combine them; trimming any one of those steps to hit a line-count target would mean the example either doesn't parallelize correctly or doesn't produce a correct total. lineWaiver: true is set deliberately for that reason.

How it works

  • typeof process.send === "function" is true only inside a process that was itself spawned with an IPC channel — which is exactly what fork() creates. That's the flag this file uses to tell "am I the parent or one of my own children" apart, the child_process equivalent of worker_threads' isMainThread.
  • The parent computes one contiguous range per core (chunk), then for each core calls fork(fileURLToPath(import.meta.url)) — forking this same compiled file — and sends it { start, end } via worker.send().
  • Each child's process.on("message", ...) receives its range, counts primes in it with the plain countPrimes loop, and reports the count back with process.send() — the same function whose existence was the branch condition a moment earlier.
  • The parent awaits each child's response with once(worker, "message") (the same events.once() from earlier in this series), disconnects that child, and once every chunk is back, sums them with reduce.

Gotchas

  • fork() creates an IPC channel by default, which is what makes .send()/process.on("message") available — spawn() and exec() don't give you that channel, and are the wrong tool if you need structured back-and-forth like this.
  • Each forked process pays real startup cost (a fresh Node runtime, not just a thread) — this pattern pays off for chunky, genuinely CPU-bound work, not for many small tasks where the fork overhead dominates.
  • worker.disconnect() closes the IPC channel and lets the child exit on its own; forgetting it leaves forked processes running indefinitely, waiting for messages that will never come.

Related

  • worker_threads — the lighter-weight sibling for the same "parallelize CPU-bound work" goal, trading process isolation for lower overhead and shared-memory transfer.
  • events.once() — used here to turn a one-shot 'message' event into an awaitable value, the same pattern as awaiting any other single event.