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"istrueonly inside a process that was itself spawned with an IPC channel — which is exactly whatfork()creates. That's the flag this file uses to tell "am I the parent or one of my own children" apart, thechild_processequivalent ofworker_threads'isMainThread.- The parent computes one contiguous range per core (
chunk), then for each core callsfork(fileURLToPath(import.meta.url))— forking this same compiled file — and sends it{ start, end }viaworker.send(). - Each child's
process.on("message", ...)receives its range, counts primes in it with the plaincountPrimesloop, and reports the count back withprocess.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 sameevents.once()from earlier in this series), disconnects that child, and once every chunk is back, sums them withreduce.
Gotchas
fork()creates an IPC channel by default, which is what makes.send()/process.on("message")available —spawn()andexec()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.