LEVEL 4 · LESSON 4 OF 13

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.

  • 55 min to read and try
  • You need: What Node.js is, Files, paths and your computer, and Streams and buffers
  • You build: A supervisor that runs an invoice worker in a child process, restarts it after a crash, skips a poison job and shuts down cleanly on SIGTERM
Test yourself

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:

  1. 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.
  2. A PDF invoice has to be made. Your team already has a program that does it, and it is not written in JavaScript.
  3. 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.
  4. 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.

order-service.jsNode.js only
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"));
Output of node order-service.js
ORD-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
  • emit returns true when at least one listener was called, and false when 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() and listenerCount() let you inspect an emitter, which is useful in tests and when you hunt for leaks.
  • #orders is 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:

sync-emit.jsNode.js only
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");
Output of node sync-emit.js
before 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:

async-listener.jsNode.js only
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");
Output of node async-listener.js
emit 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:

wait-once.jsNode.js only
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);
}
Output of node wait-once.js
connected 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:

off.jsNode.js only
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");
Output of node off.js
listeners 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 prices have 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:

leak.jsNode.js only
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"));
Output of node leak.js
listeners: 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 use EventTarget (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:

EventWhen it firesWhat to do in it
"exit"The process is about to end, for any reason except a kill signalSynchronous 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 catchLog it, then exit. The program is in an unknown state
"unhandledRejection"A rejected promise had no handlerLog it and exit; since Node.js 15 the default is to crash
"warning"Node.js emits a warning, such as the listener leakSend it to your logs
"SIGTERM", "SIGINT"The operating system sends a signalStart a graceful shutdown (see Signals)
last-words.jsNode.js only
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");
Output of node last-words.js
the 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 add process.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, ffmpeg for 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:

FunctionStartsOutputUse it for
execFile(file, args)The program directly, arguments as an arrayBuffered, handed over at the endShort commands with small output. Your default
spawn(file, args)The program directlyStreams, as it arrivesLong-running programs, big output, feeding stdin
exec(command)A shell (sh or cmd.exe) that parses one command stringBufferedYour own fixed shell commands with pipes. Never with user input
fork(modulePath)Another Node.js programInherited, plus a message channelNode.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

render-invoice.jsNode.js only
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));
Output of node render-invoice.js
stdout: "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 at process.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:

child-fails.jsNode.js only
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')");
Output of node child-fails.js
renders: 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:

limits.jsNode.js only
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);
}
Output of node limits.js
hung child: { killed: true, signal: 'SIGTERM' }
chatty child: ERR_CHILD_PROCESS_STDIO_MAXBUFFER

If 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:

injection.jsNode.js only
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"));
Output of node injection.js
exec:     [ '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":

pricing.jsNode.js only
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);
Output of node pricing.js
child 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:

tax-helper.jsNode.js only
process.on("message", (order) => {
  const vat = Math.round(order.amount * 0.075);
  process.send({ orderId: order.orderId, vat, total: order.amount + vat });
});
fork-tax.jsNode.js only
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);
Output of node fork-tax.js
{ orderId: 'ORD-5', vat: 3375, total: 48375 }
{ orderId: 'ORD-6', vat: 938, total: 13438 }
helper exited with code 0

An 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:

SignalNumberSent whenCan the program handle it?
SIGINT2You press Ctrl + C in the terminalYes
SIGTERM15Docker, Kubernetes, systemd or kill asks the program to stopYes
SIGKILL9A stop that cannot be refused, often after SIGTERM was ignored too longNo, the process dies at once
SIGHUP1The terminal window was closedYes

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):

kill-child.jsNode.js only
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");
Output of node kill-child.js
no 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 null and the signal's name in signal. Shells report it as 128 + 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 ready only 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 printed ready first 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:

graceful.jsNode.js only
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`);
Output of node graceful.js
processing 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:

shop-server.js
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:

Terminal on your computer
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, and child.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:

report-worker.jsNode.js only
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:

report.jsNode.js only
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);
Output of node report.js
card      ₦839,540,969.29
transfer  ₦841,524,004.44
cash      ₦850,430,935.59
heartbeat kept beating during the report: true
  • workerData is copied into the worker when it starts. parentPort.postMessage sends 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 beats stays 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:

worker-copy.jsNode.js only
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);
Output of node worker-copy.js
worker saw 2 items; main still has 1
DataCloneError: total() {} could not be cloned.
worker error: cart too big for this worker
worker exit code: 1

Notice 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…UseWhy
Waiting: database, files, HTTP callsPlain async/awaitThe event loop already handles thousands of waits at once
Heavy JavaScript calculationWorker threadsRuns on another core, cheap to start, fast messages
Another program, or code that may crash or leakChild processSeparate memory; a crash cannot take the parent down
A web server that should use every coreSeveral 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:

machine-for-workers.jsNode.js only
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 });
Output of node machine-for-workers.js
worker 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.signals maps signal names to numbers, which explains the 128 + n exit codes.
  • os.tmpdir() is the system's temporary folder. mkdtemp creates 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:

  1. start an invoice worker as a child process with fork and send it jobs one at a time;
  2. notice when the worker crashes, log the reason and start a new one;
  3. 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;
  4. 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:

invoice-worker.jsNode.js only
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" });
supervisor.jsNode.js only
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);
Output of node supervisor.js
supervisor: 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:

  • runJob races 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 first Error line.
  • 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 captureRejections plus an "error" listener, or catch inside.
  • Listeners leak, one per request. Watch for MaxListenersExceededWarning in your logs.
  • A child never ends. Always pass a timeout or 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:

wait-for.jsNode.js only
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");
Output of node wait-for.js
PASS 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. execFile and spawn with 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
watch-stock.jsNode.js only
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"));
Output of node watch-stock.js
SHOE-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
check-node.jsNode.js only
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));
Output of node check-node.js
node: 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
report-pool.jsNode.js only
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);
Output of 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 EventEmitter to announce what a class did. emit is synchronous: listeners run before it returns, and a throwing listener stops the rest. Use captureRejections for async listeners, and events.once to await an event.
  • Remove listeners you add per request, with the same function reference. MaxListenersExceededWarning is a leak detector, not a limit to raise.
  • process events: "exit" is synchronous only; after "uncaughtException" or "unhandledRejection", log and exit.
  • Child processes: execFile by default, spawn for streams, fork for Node.js helpers with messages, exec only for fixed commands. Set a timeout, handle exit codes and ENOENT, 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.