Node.js Core
Events, processes and workers
Coordinate a Node.js program with EventEmitter, run other programs as child processes, move heavy work to worker threads, and shut down cleanly on SIGTERM.
BY THE END OF THIS LESSON YOU CAN
- Build services on EventEmitter and avoid its traps: synchronous emit, async listeners, leaked listeners
- Run other programs with execFile and spawn, pass arguments safely and handle exit codes, timeouts and output limits
- Move CPU-heavy JavaScript to a worker thread so the event loop stays responsive
- Handle SIGTERM and SIGINT with a graceful shutdown that finishes in-flight work and has a deadline
- Supervise a child process: detect a crash, restart it and stop retrying a poison job
One program, four jobs it cannot do alone
Picture the order server of an online shop. A customer pays ₦45,000 for a pair of shoes, and four things have to happen:
- Several parts of the program must hear about the payment: stock must go down, a receipt e-mail must go out, the analytics counter must go up. The payment code should not have to know about all of them.
- A PDF invoice has to be made. Your team already has a program that does it, and it is not written in JavaScript.
- At midnight a sales report adds up ten million orders. That takes a second or more of pure calculation, and while it runs the server must still answer customers.
- Twice a day a new version is deployed. The hosting platform tells the old server to stop. A payment that is half written to the database must not be cut in the middle.
Each job needs a different Node.js tool: events for the first, child processes for the second, worker threads for the third and signals for the fourth. You met plain EventEmitter and the process object in Files, paths and your computer and What Node.js is. This lesson goes deeper into each tool, shows how it fails, and ends with a small supervisor that uses all of them together.
Every example needs Node.js, so they are marked Node.js only. Run them in your learn-node folder with node file.js.
EventEmitter in depth
Real Node.js code rarely creates a bare new EventEmitter(). It extends it: a class does its own job and announces what happened, and other code listens. This is how http.Server, streams and child processes work inside Node.js.
import { EventEmitter } from "node:events";
class OrderService extends EventEmitter {
#orders = new Map();
pay(orderId, amount) {
if (!(amount > 0)) throw new RangeError(`Invalid amount: ${amount}`);
const order = { orderId, amount, status: "paid" };
this.#orders.set(orderId, order);
const heard = this.emit("paid", order);
console.log(`${orderId} paid, listeners notified: ${heard}`);
return order;
}
}
const orders = new OrderService();
orders.pay("ORD-1", 45_000);
orders.on("paid", (order) => console.log(`[stock] reserve items for ${order.orderId}`));
orders.on("paid", (order) => console.log(`[mail] receipt for ₦${order.amount.toLocaleString("en-NG")}`));
orders.pay("ORD-2", 12_500);
console.log("events:", orders.eventNames(), "paid listeners:", orders.listenerCount("paid"));
node order-service.jsORD-1 paid, listeners notified: false [stock] reserve items for ORD-2 [mail] receipt for ₦12,500 ORD-2 paid, listeners notified: true events: [ 'paid' ] paid listeners: 2
emitreturnstruewhen at least one listener was called, andfalsewhen nobody was listening. The first payment happened before anyone subscribed, so nobody heard it. Events are not stored: a listener added later does not see earlier events.eventNames()andlistenerCount()let you inspect an emitter, which is useful in tests and when you hunt for leaks.#ordersis a private field: listeners get the order they are given, not the whole store.
emit is synchronous
This surprises almost everyone. emit does not schedule anything. It calls every listener, one after another, before it returns. So a listener that throws stops the listeners after it, and the error flies out of emit into the code that emitted:
import { EventEmitter } from "node:events";
const orders = new EventEmitter();
orders.on("paid", (order) => console.log(`[stock] reserve items for ${order.orderId}`));
orders.on("paid", () => {
throw new Error("analytics server unreachable");
});
orders.on("paid", (order) => console.log(`[mail] receipt for ${order.orderId}`));
function pay(orderId) {
console.log("before emit");
try {
orders.emit("paid", { orderId });
} catch (error) {
console.log("pay() got the listener's error:", error.message);
}
console.log("after emit");
}
pay("ORD-3");
node sync-emit.jsbefore emit [stock] reserve items for ORD-3 pay() got the listener's error: analytics server unreachable after emit
The customer paid, but no receipt was sent, because the analytics listener failed first. Two rules follow. First, a listener that can fail must catch its own errors: a side job such as analytics should never break the payment. Second, if a listener does slow work, it must not do it synchronously, or every emit waits for it.
Async listeners and captureRejections
So you make the e-mail listener async. Now a new problem appears: emit ignores the promise the listener returns. If it rejects, nobody is waiting for it, and an unhandled rejection crashes a Node.js program. The option captureRejections: true tells the emitter to catch those rejections and turn them into an "error" event:
import { EventEmitter } from "node:events";
const orders = new EventEmitter({ captureRejections: true });
orders.on("paid", async (order) => {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error(`mail server refused receipt for ${order.orderId}`);
});
orders.on("error", (error) => {
console.log("[error handler]", error.message);
});
const heard = orders.emit("paid", { orderId: "ORD-4" });
console.log("emit returned", heard, "before the e-mail was even tried");
node async-listener.jsemit returned true before the e-mail was even tried [error handler] mail server refused receipt for ORD-4
Without captureRejections the same program would end with an unhandled rejection. With it, you still need an "error" listener: as you saw in the earlier lesson, an "error" event that nobody listens for throws.
Waiting for one event: events.once
Often you want to wait for an event: "continue when the database says ready". The function once from node:events (not the method) returns a promise. It resolves with an array of the event's arguments, and it rejects if the emitter emits "error" first:
import { EventEmitter, once } from "node:events";
function connect(shouldFail) {
const db = new EventEmitter();
setTimeout(() => {
if (shouldFail) db.emit("error", new Error("connection refused"));
else db.emit("ready", "postgres", 5432);
}, 20);
return db;
}
const [name, port] = await once(connect(false), "ready");
console.log(`connected to ${name} on port ${port}`);
try {
await once(connect(true), "ready");
} catch (error) {
console.log("startup failed:", error.message);
}
node wait-once.jsconnected to postgres on port 5432 startup failed: connection refused
This is the clean way to turn "wait for an event" into await, and it is what you will use in tests.
Removing listeners
off(name, fn) (also called removeListener) removes a listener, but only when you pass the same function you added. An arrow function written inline cannot be removed later, because you have no reference to it:
import { EventEmitter } from "node:events";
const stock = new EventEmitter();
const logLow = (item) => console.log(`low stock: ${item}`);
stock.on("low", logLow);
stock.on("low", (item) => console.log(`reorder ${item}`));
stock.off("low", logLow);
stock.off("low", (item) => console.log(`reorder ${item}`));
console.log("listeners left:", stock.listenerCount("low"));
stock.emit("low", "sneakers size 42");
node off.jslisteners left: 1 reorder sneakers size 42
The second off did nothing: it created a new function that looks the same but is a different object. Keep a reference to every listener you plan to remove.
Listener leaks
REASON IT OUT
A listener per request
A developer wants each HTTP request to log when the shop's price list is reloaded. Inside the request handler they write prices.on("reloaded", () => log(requestId)). The server handles 2,000 requests an hour and restarts once a week. Before reading on, think it through:
- How many listeners does
priceshave after one hour? After a week? - What does each listener keep alive?
- What happens on the next
"reloaded"event? - How would you notice before the server runs out of memory?
Show the reasoning
Nothing ever removes the listeners. After one hour there are 2,000; after a week, about 336,000. Each one is a closure, so it keeps its requestId and anything else it refers to (maybe the whole request object) alive: the garbage collector can never free them. The next "reloaded" event calls all 336,000 functions synchronously, freezing the server. This is a memory leak: memory that is still reachable but will never be used again.
Node.js helps you notice. When an emitter gets more than 10 listeners for one event, it prints a warning. Ten is not a hard limit; it is a smoke detector. The fix is to add the listener once, at startup, or to remove it when the request ends.
Here is the warning. It normally goes to stderr; the program also catches it with process.on("warning") so you can see its parts:
import { EventEmitter } from "node:events";
class PriceList extends EventEmitter {}
const prices = new PriceList();
process.on("warning", (warning) => {
console.log(warning.name);
console.log(warning.message);
});
function handleRequest(requestId) {
prices.on("reloaded", () => console.log(`request ${requestId} saw new prices`));
}
for (let id = 1; id <= 11; id++) handleRequest(id);
console.log("listeners:", prices.listenerCount("reloaded"));
node leak.jslisteners: 11 MaxListenersExceededWarning Possible EventEmitter memory leak detected. 11 reloaded listeners added to [PriceList]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
The warning names the class (PriceList), the event and the count. When you see it, do not just raise the limit with setMaxListeners. First ask why listeners keep being added. Raise the limit only when many listeners are genuinely expected, such as one per connected WebSocket client that removes itself on disconnect.
TIP
Browsers useEventTarget (addEventListener) instead of EventEmitter, and Node.js has it too. addEventListener accepts { signal }: aborting that signal removes the listener, which is a tidy way to remove many listeners at once. You will use it in Events in the browser.Events on the process itself
The global process is an event emitter too. Its events tell you about the life of the whole program:
| Event | When it fires | What to do in it |
|---|---|---|
"exit" | The process is about to end, for any reason except a kill signal | Synchronous last words only; timers and promises no longer run |
"beforeExit" | The event loop is empty (not after process.exit()) | Rarely needed |
"uncaughtException" | A thrown error reached the top with no catch | Log it, then exit. The program is in an unknown state |
"unhandledRejection" | A rejected promise had no handler | Log it and exit; since Node.js 15 the default is to crash |
"warning" | Node.js emits a warning, such as the listener leak | Send it to your logs |
"SIGTERM", "SIGINT" | The operating system sends a signal | Start a graceful shutdown (see Signals) |
process.on("exit", (code) => {
console.log(`exit event, code ${code}`);
setTimeout(() => console.log("this never prints"), 0);
});
process.on("unhandledRejection", (reason) => {
console.log("unhandled rejection:", reason.message);
process.exitCode = 1;
});
async function chargeCard(amount) {
throw new Error(`card declined for ₦${amount}`);
}
chargeCard(45_000);
console.log("the main code finished");
node last-words.jsthe main code finished unhandled rejection: card declined for ₦45000 exit event, code 1
The rejected promise from chargeCard had no await and no .catch, so the handler caught it. The setTimeout inside "exit" never runs: by then the event loop has stopped. Do not try to save data or send a log over the network in "exit".
DO NOT KEEP RUNNING AFTER UNCAUGHT ERRORS
It is tempting to addprocess.on("uncaughtException", () => {}) so the server "never crashes". Don't. The error stopped some function halfway: a transfer may have left one account and never arrived in the other, a lock may be held forever. The safe response is to log the error, stop taking new work and exit with a non-zero code, and let a supervisor (Docker, systemd, Kubernetes, or the one you build below) start a fresh process.Running other programs: child processes
A child process is another program your program starts. It runs separately, with its own memory, and your program talks to it through its standard streams: stdin (what it reads), stdout (what it prints) and stderr (its errors), plus its exit code. You use one when:
- the job is done by another program:
git,ffmpegfor video, an invoice renderer, a Python script; - code might crash or hang, and must not take your server down with it;
- you want another Node.js program to run in parallel.
node:child_process has four ways to start one:
| Function | Starts | Output | Use it for |
|---|---|---|---|
execFile(file, args) | The program directly, arguments as an array | Buffered, handed over at the end | Short commands with small output. Your default |
spawn(file, args) | The program directly | Streams, as it arrives | Long-running programs, big output, feeding stdin |
exec(command) | A shell (sh or cmd.exe) that parses one command string | Buffered | Your own fixed shell commands with pipes. Never with user input |
fork(modulePath) | Another Node.js program | Inherited, plus a message channel | Node.js helpers you talk to with messages |
The examples start Node.js itself as the child, with node -e "code", so they work on every computer. process.execPath is the full path of the node program that is running now, which is safer than hoping node is on the PATH.
execFile: run, wait, read the output
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const renderer = `
const [invoiceId, amount] = process.argv.slice(1);
console.log("rendered " + invoiceId + " for NGN " + amount);
`;
const { stdout, stderr } = await run(process.execPath, ["-e", renderer, "INV-1042", "45000"]);
console.log("stdout:", JSON.stringify(stdout));
console.log("stderr:", JSON.stringify(stderr));
node render-invoice.jsstdout: "rendered INV-1042 for NGN 45000\n" stderr: ""
promisify(execFile)turns the callback function into one that returns a promise of{ stdout, stderr }. Promisifying came up in Promises in depth.- The output is text with a trailing newline, exactly as the child printed it. Trim it before you use it.
- With
node -e, the arguments after the code start atprocess.argv[1], not[2], because there is no file name.
Exit codes and failures
A child that ends with a non-zero exit code makes the promise reject. The error carries the code, and the output collected so far:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
async function tryRun(label, code) {
try {
await run(process.execPath, ["-e", code]);
console.log(`${label}: ok`);
} catch (error) {
const firstError = error.stderr.split("\n").find((line) => line.startsWith("Error"));
console.log(`${label}: exit code ${error.code}, ${firstError ?? "no message"}`);
}
}
await tryRun("renders", "console.log('done')");
await tryRun("printer offline", "throw new Error('printer offline')");
await tryRun("bad input", "console.error('Error: amount missing'); process.exit(2)");
await tryRun("missing program", "require('node:child_process').execFileSync('invoice-pdf-renderer')");
node child-fails.jsrenders: ok printer offline: exit code 1, Error: printer offline bad input: exit code 2, Error: amount missing missing program: exit code 1, Error: spawnSync invoice-pdf-renderer ENOENT
An uncaught error in the child gives exit code 1. A program can choose its own codes, like 2 for bad input, so the parent can tell kinds of failure apart. The last child tried to start a program that does not exist; in the child that is an ENOENT error ("no such file"), the same code you met for missing files.
Timeouts and output limits
A child can hang forever: a renderer waiting for a network drive, a script stuck in a loop. Always set a timeout. When it passes, Node.js kills the child (with SIGTERM by default) and rejects. The buffered functions also have a maxBuffer, 1 MiB of output by default. A child that prints more is killed:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
try {
await run(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeout: 300 });
} catch (error) {
console.log("hung child:", { killed: error.killed, signal: error.signal });
}
try {
await run(process.execPath, ["-e", "console.log('x'.repeat(5000))"], { maxBuffer: 1024 });
} catch (error) {
console.log("chatty child:", error.code);
}
node limits.jshung child: { killed: true, signal: 'SIGTERM' }
chatty child: ERR_CHILD_PROCESS_STDIO_MAXBUFFERIf you expect a lot of output, such as a log or a generated file, use spawn and read it as a stream instead of raising maxBuffer.
Shell injection
REASON IT OUT
The customer's name goes into a command
Your invoice renderer is a command-line program. A developer builds the command with a template string and runs it with exec: exec(`render-invoice --name "${customer.name}"`). The name comes from the sign-up form. Think before you read on:
- Who decides what is inside
customer.name? - Which program reads the command string before the renderer does?
- Which characters mean something special to that program?
Show the reasoning
The customer decides the name, so it is untrusted input. exec hands the whole string to a shell, and the shell treats ;, &&, |, $( ), backticks and quotes as instructions. A name such as Ada"; rm -rf ~; echo " closes the quote and adds commands of the customer's choosing, which run with your server's permissions. This is shell injection (also called command injection). Quoting or escaping by hand is easy to get wrong. The fix is to not use a shell at all: execFile and spawn pass each argument to the program as a separate value, and no shell ever reads it.
Here is the attack, made harmless: the injected command only prints a line. The example uses echo, a command that exists on macOS and Linux:
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
const runShell = promisify(exec);
const run = promisify(execFile);
const name = 'Ada"; echo "INJECTED: your server runs my command';
const viaShell = await runShell(`echo "Invoice for ${name}"`);
console.log("exec: ", viaShell.stdout.trim().split("\n"));
const direct = await run("echo", ["Invoice for", name]);
console.log("execFile:", direct.stdout.trim().split("\n"));
node injection.jsexec: [ 'Invoice for Ada', 'INJECTED: your server runs my command' ] execFile: [ 'Invoice for Ada"; echo "INJECTED: your server runs my command' ]
Through the shell, the name became a second command. With execFile, the same text was just an argument, printed as it is. Use exec only for fixed commands you wrote yourself, and never pass { shell: true } to spawn or execFile with user input either.
spawn: streams in and out
spawn gives you the child's streams directly. This child is a pricing program: it reads an order as JSON from stdin, reports progress on stdout line by line, and ends with the total. The parent writes the order, reads the lines as they arrive with readline (from Streams and buffers), and waits for "close":
import { spawn } from "node:child_process";
import { once } from "node:events";
import { createInterface } from "node:readline";
const pricer = `
let input = "";
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
const order = JSON.parse(input);
let total = 0;
for (const item of order.items) {
total += item.price * item.qty;
console.log("priced " + item.sku);
}
console.log("TOTAL " + total);
});
`;
const child = spawn(process.execPath, ["-e", pricer]);
child.stdin.end(JSON.stringify({ items: [
{ sku: "SHOE-42", price: 45_000, qty: 1 },
{ sku: "SOCK-3", price: 2_500, qty: 3 },
] }));
for await (const line of createInterface({ input: child.stdout })) {
console.log("child says:", line);
}
const [code, signal] = await once(child, "close");
console.log("closed with code", code, "signal", signal);
node pricing.jschild says: priced SHOE-42 child says: priced SOCK-3 child says: TOTAL 52500 closed with code 0 signal null
child.stdin.end(text)writes the text and closes the stream, which is how the child knows the input is complete (its"end"event)."exit"fires when the child process ends;"close"fires after that, when its streams are also closed. Wait for"close"when you read its output, so you never miss the last lines.- A child that fails to start at all (the program does not exist) emits
"error", not"exit". Listen for it, or the error crashes your program.
fork: a Node.js helper with a message channel
fork starts a Node.js file and opens an IPC channel (inter-process communication) between the two programs. Each side sends objects with send and receives them as "message" events; they are converted to JSON and back on the way. Save the helper as tax-helper.js:
process.on("message", (order) => {
const vat = Math.round(order.amount * 0.075);
process.send({ orderId: order.orderId, vat, total: order.amount + vat });
});
import { fork } from "node:child_process";
import { once } from "node:events";
const helper = fork("./tax-helper.js");
for (const order of [{ orderId: "ORD-5", amount: 45_000 }, { orderId: "ORD-6", amount: 12_500 }]) {
helper.send(order);
const [reply] = await once(helper, "message");
console.log(reply);
}
helper.disconnect();
const [code] = await once(helper, "exit");
console.log("helper exited with code", code);
node fork-tax.js{ orderId: 'ORD-5', vat: 3375, total: 48375 }
{ orderId: 'ORD-6', vat: 938, total: 13438 }
helper exited with code 0An open IPC channel keeps both programs alive. disconnect() closes it, the helper has nothing left to wait for, and it exits by itself with code 0.
Signals and graceful shutdown
A signal is a short message the operating system delivers to a process, identified by a name and a number. You will meet four:
| Signal | Number | Sent when | Can the program handle it? |
|---|---|---|---|
SIGINT | 2 | You press Ctrl + C in the terminal | Yes |
SIGTERM | 15 | Docker, Kubernetes, systemd or kill asks the program to stop | Yes |
SIGKILL | 9 | A stop that cannot be refused, often after SIGTERM was ignored too long | No, the process dies at once |
SIGHUP | 1 | The terminal window was closed | Yes |
If a Node.js program has no listener for SIGINT or SIGTERM, it dies immediately, in the middle of whatever it was doing. A listener replaces that default: the program keeps running until you end it. A parent sends a signal to its child with child.kill(signalName):
import { spawn } from "node:child_process";
import { once } from "node:events";
async function stop(label, code, signal) {
const child = spawn(process.execPath, ["-e", code]);
child.stdout.setEncoding("utf8");
await once(child.stdout, "data");
child.kill(signal);
let said = "";
child.stdout.on("data", (text) => (said += text));
const [exitCode, exitSignal] = await once(child, "close");
console.log(`${label}: code ${exitCode}, signal ${exitSignal}, said ${JSON.stringify(said.trim())}`);
}
const busy = "console.log('ready'); setInterval(() => {}, 1000);";
const polite = `
const timer = setInterval(() => {}, 1000);
process.on('SIGTERM', () => { console.log('closing'); clearInterval(timer); });
console.log('ready');
`;
await stop("no handler, SIGTERM", busy, "SIGTERM");
await stop("no handler, SIGINT ", busy, "SIGINT");
await stop("handler, SIGTERM", polite, "SIGTERM");
await stop("handler, SIGKILL", polite, "SIGKILL");
node kill-child.jsno handler, SIGTERM: code null, signal SIGTERM, said "" no handler, SIGINT : code null, signal SIGINT, said "" handler, SIGTERM: code 0, signal null, said "closing" handler, SIGKILL: code null, signal SIGKILL, said ""
- A process killed by a signal has exit code
nulland the signal's name insignal. Shells report it as128 + number: 130 after Ctrl + C, 143 after SIGTERM, 137 after SIGKILL. You will see 137 in Docker when a container used too much memory. - The child that listened for SIGTERM said "closing", stopped its timer, and ended normally with code 0 once nothing was left to do.
- SIGKILL ignored the handler completely. That is why your shutdown code must be quick: the platform sends SIGKILL if you take too long (Docker waits 10 seconds, Kubernetes 30 by default).
- The child prints
readyonly after it has set up its handler, and the parent waits for that line before sending the signal. When this lesson was written, the first version printedreadyfirst and registered the handler one line later. The parent was fast enough to send SIGTERM in between, and the "polite" child died like the others. Always announce readiness last.
A graceful shutdown
A graceful shutdown means: stop taking new work, finish the work in progress, close connections, then exit. And, because "finish" might never happen, give up after a deadline. Here the "server" is a loop that processes payments, each taking a moment, and the program sends itself SIGTERM halfway, the way a deployment would:
const queue = ["PAY-1", "PAY-2", "PAY-3", "PAY-4", "PAY-5"];
let shuttingDown = false;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function processPayments() {
while (queue.length > 0 && !shuttingDown) {
const id = queue.shift();
console.log(`processing ${id}`);
await wait(100);
console.log(`committed ${id}`);
}
}
function shutdown(signal) {
if (shuttingDown) {
console.log(`second ${signal}: exiting now`);
process.exit(1);
}
shuttingDown = true;
console.log(`${signal} received: finishing current payment, taking no new ones`);
setTimeout(() => {
console.log("shutdown took too long, giving up");
process.exit(1);
}, 5_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
setTimeout(() => process.kill(process.pid, "SIGTERM"), 150);
await processPayments();
console.log(`stopped cleanly, ${queue.length} payments left for the next process`);
node graceful.jsprocessing PAY-1 committed PAY-1 processing PAY-2 SIGTERM received: finishing current payment, taking no new ones committed PAY-2 stopped cleanly, 3 payments left for the next process
- The signal arrived while PAY-2 was being processed. PAY-2 was still committed, then the loop stopped taking new ones. Nothing was cut in half, and the remaining payments stay in the queue (in real life, a database table) for the new version.
- The deadline timer is
unref()ed: an unref'd timer does not keep the process alive by itself, so a quick shutdown exits at once instead of waiting 5 seconds. - A second Ctrl + C exits immediately. People press it again when they are impatient, and should get what they asked for.
- An HTTP server shuts down the same way:
server.close()stops accepting connections and calls back when open requests have finished. Building a Task API uses it.
Here is a real server that handles SIGTERM this way:
import http from "node:http";
const server = http.createServer((req, res) => res.end("ok\n"));
server.listen(3000, () => console.log("listening on port 3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received: closing the server");
server.close(() => console.log("all requests finished, bye"));
});
Start it in the background with &, then stop it with the kill command, which sends SIGTERM by default. $! is the process id of the last program started in the background:
node shop-server.js & listening on port 3000 kill $! SIGTERM received: closing the server all requests finished, bye [1]+ Done node shop-server.js
Nothing more had to be done after server.close(): once the server and its connections were closed, the event loop was empty and Node.js ended with code 0 by itself. Your terminal also prints a job number and process id right after the first command, such as [1] 48213.
NOTE
Windows has no real signals. Node.js emulates SIGINT for Ctrl + C in a console, andchild.kill() always ends the child forcefully there, whatever the signal name. Servers usually run on Linux, so write your shutdown for SIGTERM and SIGINT and test it there, for example in Docker.Worker threads for heavy work
Back to the midnight sales report. It is pure calculation: no files, no network, just a loop over ten million orders. Making it async does not help, because async only helps while you wait for something. While JavaScript is calculating, the event loop is busy and every other request waits, as you saw in Do not block the loop.
A thread is a separate line of execution inside the same process. node:worker_threads starts a worker: a second JavaScript engine with its own event loop, running a file of your choice on another processor core. The main thread stays free. Save the report as report-worker.js:
import { parentPort, workerData } from "node:worker_threads";
function salesReport(orderCount) {
const totals = { card: 0, transfer: 0, cash: 0 };
const methods = ["card", "transfer", "cash"];
let seed = 42;
for (let i = 0; i < orderCount; i++) {
seed = (seed * 1103515245 + 12345) % 2147483648;
const amount = 500 + (seed % 49_500);
totals[methods[seed % 3]] += amount;
}
return totals;
}
parentPort.postMessage(salesReport(workerData.orderCount));
The orders are made up with a fixed formula (a seeded pseudo-random generator) so the totals are the same on every run. The main program starts the worker and keeps a heartbeat timer running at the same time, to prove the event loop is still free:
import { Worker } from "node:worker_threads";
import { once } from "node:events";
let beats = 0;
const heartbeat = setInterval(() => beats++, 50);
const worker = new Worker(new URL("./report-worker.js", import.meta.url), {
workerData: { orderCount: 10_000_000 },
});
const [totals] = await once(worker, "message");
clearInterval(heartbeat);
for (const [method, kobo] of Object.entries(totals)) {
console.log(method.padEnd(9), "₦" + (kobo / 100).toLocaleString("en-NG", { minimumFractionDigits: 2 }));
}
console.log("heartbeat kept beating during the report:", beats >= 3);
node report.jscard ₦839,540,969.29 transfer ₦841,524,004.44 cash ₦850,430,935.59 heartbeat kept beating during the report: true
workerDatais copied into the worker when it starts.parentPort.postMessagesends the result back; the main thread receives it as a"message"event.new URL("./report-worker.js", import.meta.url)finds the file next to the current module, wherever you start the program from.- The heartbeat timer kept firing while ten million orders were added up. Run the same loop on the main thread and
beatsstays 0 until it ends.
Messages are copies
Workers do not share ordinary objects. Everything you post is copied with the structured clone algorithm (the one behind structuredClone), and functions cannot be copied at all. A worker that throws emits "error" in the main thread, and then "exit" with code 1:
import { Worker } from "node:worker_threads";
import { once } from "node:events";
const code = `
const { parentPort } = require("node:worker_threads");
parentPort.on("message", (cart) => {
cart.items.push("changed inside the worker");
parentPort.postMessage(cart.items.length);
if (cart.items.length > 2) throw new Error("cart too big for this worker");
});
`;
const cart = { items: ["SHOE-42"] };
const worker = new Worker(code, { eval: true });
worker.postMessage(cart);
console.log("worker saw", (await once(worker, "message"))[0], "items; main still has", cart.items.length);
try {
worker.postMessage({ items: ["A", "B"], total() {} });
} catch (error) {
console.log(error.name + ":", error.message);
}
worker.on("error", (error) => console.log("worker error:", error.message));
const exited = new Promise((resolve) => worker.on("exit", resolve));
worker.postMessage({ items: ["A", "B"] });
console.log("worker exit code:", await exited);
node worker-copy.jsworker saw 2 items; main still has 1
DataCloneError: total() {} could not be cloned.
worker error: cart too big for this worker
worker exit code: 1Notice the last lines wait for "exit" with a plain promise, not with once(worker, "exit"). once rejects when the emitter emits "error" while it waits, and this worker is expected to fail, so once would have thrown the worker's error at the await and crashed the program. That is what happened when this example was first run.
For very large data, copying costs time. You can transfer an ArrayBuffer instead (it moves to the worker and becomes unusable in the sender), or share memory with a SharedArrayBuffer. Most programs never need either; send small inputs and small results.
Which tool for which job
| The work is… | Use | Why |
|---|---|---|
| Waiting: database, files, HTTP calls | Plain async/await | The event loop already handles thousands of waits at once |
| Heavy JavaScript calculation | Worker threads | Runs on another core, cheap to start, fast messages |
| Another program, or code that may crash or leak | Child process | Separate memory; a crash cannot take the parent down |
| A web server that should use every core | Several processes (node:cluster, or several containers) | Each process has its own event loop behind one port or a load balancer |
Starting a worker costs several milliseconds and a few megabytes, so busy servers keep a pool: a fixed set of workers, usually one per core, that take jobs from a queue. os.availableParallelism() tells you how many cores there are.
The os module for process work
Files, paths and your computer used node:os for the platform and memory. A few more parts matter when you manage processes:
import os from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
const cores = os.availableParallelism();
console.log("worker pool size:", Math.max(1, cores - 1), "(one core left for the main thread)");
const [oneMinute] = os.loadavg();
console.log("load average is a number:", typeof oneMinute === "number");
console.log("uptime in seconds is positive:", os.uptime() > 0);
const { SIGINT, SIGTERM, SIGKILL } = os.constants.signals;
console.log({ SIGINT, SIGTERM, SIGKILL });
console.log("shell exit code after SIGTERM:", 128 + SIGTERM);
const scratch = await mkdtemp(join(os.tmpdir(), "invoices-"));
console.log("private temp folder inside tmpdir:", scratch.startsWith(os.tmpdir()));
await rm(scratch, { recursive: true });
node machine-for-workers.jsworker pool size: 7 (one core left for the main thread)
load average is a number: true
uptime in seconds is positive: true
{ SIGINT: 2, SIGTERM: 15, SIGKILL: 9 }
shell exit code after SIGTERM: 143
private temp folder inside tmpdir: true- Load average (
os.loadavg(), macOS and Linux) is the average number of processes wanting a processor over the last 1, 5 and 15 minutes. A value above the number of cores means work is queueing. On Windows it is always[0, 0, 0]. os.constants.signalsmaps signal names to numbers, which explains the 128 + n exit codes.os.tmpdir()is the system's temporary folder.mkdtempcreates a new, uniquely named folder inside it, the right place for a child process to write intermediate files that you delete afterwards.
Build: a supervised invoice worker
Now combine the pieces. A supervisor is a program whose job is to keep another program running. Yours will:
- start an invoice worker as a child process with
forkand send it jobs one at a time; - notice when the worker crashes, log the reason and start a new one;
- give up on a job that crashed the worker twice (a poison job) and put it aside, so one bad invoice cannot block all the others;
- on SIGTERM, let the current job finish, stop the worker and exit cleanly.
The worker renders one invoice per message. An invoice with a negative amount makes it throw, standing in for a bug that crashes a real renderer:
process.on("message", async (job) => {
await new Promise((resolve) => setTimeout(resolve, 20));
if (job.amount < 0) throw new Error(`cannot render negative amount ${job.amount}`);
process.send({ type: "done", id: job.id });
});
process.on("SIGTERM", () => {
process.disconnect();
});
process.send({ type: "ready" });
import { fork } from "node:child_process";
import { once } from "node:events";
const jobs = [
{ id: "INV-1", amount: 45_000 },
{ id: "INV-2", amount: -500 },
{ id: "INV-3", amount: 12_500 },
{ id: "INV-4", amount: 8_000 },
{ id: "INV-5", amount: 3_000 },
];
const failures = new Map();
const deadLetters = [];
let stopping = false;
function startWorker() {
const child = fork("./invoice-worker.js", { silent: true });
let stderr = "";
child.stderr.on("data", (chunk) => (stderr += chunk));
child.lastError = () => stderr.split("\n").find((line) => line.startsWith("Error")) ?? "unknown";
return child;
}
async function runJob(child, job) {
child.send(job);
const reply = once(child, "message").then(([message]) => message);
const crash = once(child, "exit").then(([code]) => ({ type: "crashed", code }));
return Promise.race([reply, crash]);
}
process.on("SIGTERM", () => {
console.log("supervisor: SIGTERM, finishing the current job");
stopping = true;
});
let worker = startWorker();
await once(worker, "message");
console.log("supervisor: worker", worker.pid > 0 ? "started" : "failed");
while (jobs.length > 0 && !stopping) {
const job = jobs.shift();
const result = await runJob(worker, job);
if (result.type === "done") {
console.log(`done ${job.id}`);
if (job.id === "INV-3") process.kill(process.pid, "SIGTERM");
continue;
}
const count = (failures.get(job.id) ?? 0) + 1;
failures.set(job.id, count);
console.log(`worker crashed on ${job.id} (exit code ${result.code}): ${worker.lastError()}`);
if (count >= 2) {
deadLetters.push(job.id);
console.log(`giving up on ${job.id} after ${count} crashes`);
} else {
jobs.unshift(job);
}
worker = startWorker();
await once(worker, "message");
console.log("supervisor: worker restarted");
}
worker.kill("SIGTERM");
const [code, signal] = await once(worker, "exit");
console.log(`worker stopped: code ${code}, signal ${signal}`);
console.log("not started:", jobs.map((job) => job.id), "dead letters:", deadLetters);
node supervisor.jssupervisor: worker started done INV-1 worker crashed on INV-2 (exit code 1): Error: cannot render negative amount -500 supervisor: worker restarted worker crashed on INV-2 (exit code 1): Error: cannot render negative amount -500 giving up on INV-2 after 2 crashes supervisor: worker restarted done INV-3 supervisor: SIGTERM, finishing the current job done INV-4 worker stopped: code 0, signal null not started: [ 'INV-5' ] dead letters: [ 'INV-2' ]
Follow the output line by line:
runJobraces two promises: the worker's reply and the worker's exit. Whichever comes first decides the result, so a crash never leaves the supervisor waiting forever for a reply.- INV-2 crashed the worker twice. The supervisor retried once (a crash can be a fluke), then moved it to
deadLetters. In a real system a person looks at dead letters; the name comes from the post office's pile of undeliverable mail. { silent: true }gives the parent the child's stdout and stderr as streams instead of mixing them into its own output. The supervisor keeps the stderr text to log the firstErrorline.- After INV-3 the program sends itself SIGTERM, standing in for a deployment. The supervisor finished nothing halfway, left INV-4 and INV-5 for the next run, and stopped the worker with SIGTERM. The worker's handler closed its IPC channel, so it ended normally with code 0.
Real supervisors, such as systemd, Docker's restart policies and Kubernetes, work on the same ideas: watch the exit, restart, back off, and give up after too many crashes in a row. Queues such as ZudoJS queues keep retries and dead letters for jobs.
Failure cases, testing and production
What goes wrong
- A listener throws and the emitting code fails with it, or later listeners never run. Catch errors inside listeners that do side jobs.
- An async listener rejects with nobody listening, and the process crashes with an unhandled rejection. Use
captureRejectionsplus an"error"listener, or catch inside. - Listeners leak, one per request. Watch for
MaxListenersExceededWarningin your logs. - A child never ends. Always pass a
timeoutor kill it yourself. - A child is never started (
ENOENT): the program is missing on the server even though it exists on your laptop. Check required programs at startup. - Orphans: when a parent is killed with SIGKILL, its children keep running. Kill children in your shutdown code.
- Shutdown never finishes because a connection or timer stays open. The deadline timer turns that into an exit with code 1, which your logs will show.
Testing
Test events by waiting for them with once, and give every wait a deadline so a broken test fails instead of hanging. Test child-process code against a tiny node -e child whose behaviour you control, as every example above did. Here is a helper with a timeout, plus a check:
import { EventEmitter, once } from "node:events";
import assert from "node:assert/strict";
async function waitFor(emitter, event, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`no "${event}" within ${ms} ms`)), ms);
try {
return await once(emitter, event, { signal: controller.signal });
} catch (error) {
throw controller.signal.aborted ? controller.signal.reason : error;
} finally {
clearTimeout(timer);
}
}
const orders = new EventEmitter();
setTimeout(() => orders.emit("paid", { orderId: "ORD-7" }), 10);
const [order] = await waitFor(orders, "paid", 500);
assert.equal(order.orderId, "ORD-7");
console.log("PASS paid event arrives");
await assert.rejects(waitFor(orders, "refunded", 50), /no "refunded" within 50 ms/);
console.log("PASS missing event times out");
node wait-for.jsPASS paid event arrives PASS missing event times out
once accepts an AbortSignal; aborting it rejects the wait with a generic AbortError, so the helper throws the signal's reason instead, which says which event was missing. The finally clears the timer so a successful test does not keep the process alive. (AbortSignal.timeout(ms) looks shorter, but its timer does not keep the process alive, so a test waiting only on it can end silently with "unsettled top-level await".)
Production concerns
- Let the platform restart you. Exit with a non-zero code on fatal errors, and let Docker, systemd or Kubernetes start a fresh process.
- Receive the signal. In Docker, start your server with
CMD ["node", "server.js"], not through a shell script, so SIGTERM reaches Node.js. Deployment covers containers. - Keep shutdown under the grace period: 10 seconds in Docker, 30 in Kubernetes by default. Put your deadline below it.
- Limit parallel children and workers. Starting one per request lets a traffic spike exhaust memory. Use a pool with a fixed size.
- Log child stderr. It is where a failing program explains itself.
- Never build shell commands from input.
execFileandspawnwith an argument array, always.
Practice
TRY IT YOURSELF
A stock alert that cleans up after itself
Write a function watchStock(stock, sku) that listens for "low" events on an emitter, logs only the ones for its sku, and returns a function that removes the listener. Show that after calling it, listenerCount("low") is 0 again.
Show a solution
import { EventEmitter } from "node:events";
function watchStock(stock, sku) {
const listener = (item) => {
if (item.sku === sku) console.log(`${sku} is low: ${item.left} left`);
};
stock.on("low", listener);
return () => stock.off("low", listener);
}
const stock = new EventEmitter();
const stopWatching = watchStock(stock, "SHOE-42");
stock.emit("low", { sku: "SHOE-42", left: 2 });
stock.emit("low", { sku: "SOCK-3", left: 1 });
stopWatching();
stock.emit("low", { sku: "SHOE-42", left: 1 });
console.log("listeners:", stock.listenerCount("low"));
node watch-stock.jsSHOE-42 is low: 2 left listeners: 0
The returned function closes over the exact listener reference, which is what off needs. Returning an "unsubscribe" function is a common pattern; many libraries do the same.
TRY IT YOURSELF
Check a required program at startup
A server needs Node.js 24 or newer in its child processes. Write checkNode() that runs node --version with execFile, parses the major version and throws a clear error if it is too old. Also show what happens when the program is missing, by running a program name that does not exist.
Show a solution
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
async function checkProgram(file, minMajor) {
try {
const { stdout } = await run(file, ["--version"], { timeout: 5_000 });
const major = Number(stdout.trim().replace(/^v/, "").split(".")[0]);
if (major < minMajor) throw new Error(`${file} ${major} is too old, need ${minMajor}+`);
return `${file}: major version ${major >= minMajor ? "ok" : "too old"}`;
} catch (error) {
if (error.code === "ENOENT") return `${file}: not installed`;
throw error;
}
}
console.log(await checkProgram(process.execPath, 24).then((text) => text.replace(process.execPath, "node")));
console.log(await checkProgram("invoice-pdf-renderer", 1));
node check-node.jsnode: major version ok invoice-pdf-renderer: not installed
When the program itself is missing, execFile rejects with error.code === "ENOENT", not with an exit code, because nothing ever ran. Checking at startup turns a mystery failure at the first invoice into a clear message when the server boots.
TRY IT YOURSELF
Split the report across workers
Change the sales report so the main program starts 4 workers, each adding up a quarter of the orders (pass start and end in workerData), and combines the results. To keep it self-contained, use an eval worker that sums the numbers from start to end - 1, and check the combined total against the formula n * (n - 1) / 2.
Show a solution
import { Worker } from "node:worker_threads";
import { once } from "node:events";
const code = `
const { parentPort, workerData } = require("node:worker_threads");
let sum = 0;
for (let i = workerData.start; i < workerData.end; i++) sum += i;
parentPort.postMessage(sum);
`;
function runPart(start, end) {
const worker = new Worker(code, { eval: true, workerData: { start, end } });
return once(worker, "message").then(([sum]) => sum);
}
const n = 40_000_000;
const parts = 4;
const size = Math.ceil(n / parts);
const ranges = Array.from({ length: parts }, (_, i) => [i * size, Math.min(n, (i + 1) * size)]);
console.log(ranges);
const sums = await Promise.all(ranges.map(([start, end]) => runPart(start, end)));
const total = sums.reduce((a, b) => a + b, 0);
console.log("total:", total, "expected:", (n * (n - 1)) / 2, "match:", total === (n * (n - 1)) / 2);
node report-pool.js[ [ 0, 10000000 ], [ 10000000, 20000000 ], [ 20000000, 30000000 ], [ 30000000, 40000000 ] ] total: 799999980000000 expected: 799999980000000 match: true
Promise.all runs the four workers at the same time and waits for all of them. Each part must be independent for this to work: splitting only helps when no part needs another part's result.
Recap
- Extend
EventEmitterto announce what a class did.emitis synchronous: listeners run before it returns, and a throwing listener stops the rest. UsecaptureRejectionsfor async listeners, andevents.onceto await an event. - Remove listeners you add per request, with the same function reference.
MaxListenersExceededWarningis a leak detector, not a limit to raise. processevents:"exit"is synchronous only; after"uncaughtException"or"unhandledRejection", log and exit.- Child processes:
execFileby default,spawnfor streams,forkfor Node.js helpers with messages,execonly for fixed commands. Set a timeout, handle exit codes andENOENT, and never build a shell command from input. - SIGINT and SIGTERM can be handled; SIGKILL cannot. A graceful shutdown stops new work, finishes current work and has a deadline.
- Worker threads run heavy JavaScript on another core; messages are copied. Use them for calculation, child processes for other programs and isolation, plain async for waiting.
Next, Cryptography with node:crypto hashes passwords, signs webhooks and encrypts data, and uses a worker-backed function, scrypt, that you will now recognise.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.