Appearance
Child Process Cleanup
When to use
Use this pattern when spawning long-lived child processes that must be reliably terminated on parent exit. Without explicit cleanup, child processes can outlive the parent — leaving orphaned servers, database proxies, or build watchers consuming resources.
The pattern
Three complementary pieces:
child.terminate()— Unpipe stdio streams, then kill the entire process tree.killTree(pid)— Recursively find all descendant PIDs and kill them child-first.cleanupOnExit(fn)— Register a cleanup function that runs on every exit path (signals, exceptions, normal exit).
Using terminate() to stop a child process
Call terminate() on a child process returned by runAsync() to cleanly shut down the process and all its descendants. This terminates a database proxy when the parent process exits:
javascript
const proxy = runAsync("cloud-sql-proxy", [instanceName]);
cleanupOnExit(async () => {
await proxy.terminate();
});When shutting down multiple child processes, terminate them in parallel to avoid delays:
javascript
const server = runAsync("node", ["server.js"]);
const watcher = runAsync("node", ["--watch", "worker.js"]);
cleanupOnExit(async () => {
await Promise.all([
server.terminate(),
watcher.terminate(),
]);
});Implementing terminate() on a child process
Attach a terminate() method to the child process object returned by spawn(). It unpipes output streams to prevent write-after-close errors, then kills the process tree:
javascript
import { spawn } from "node:child_process";
function runAsync(command, args, options) {
let stdout = "";
let stderr = "";
const child = spawn(command, args, options);
if (child.stdout) {
child.stdout.setEncoding("utf-8");
child.stdout.pipe(process.stdout);
child.stdout.on("data", (data) => stdout += data);
}
if (child.stderr) {
child.stderr.setEncoding("utf-8");
child.stderr.pipe(process.stderr);
child.stderr.on("data", (data) => stderr += data);
}
child.terminate = function terminate() {
if (this.stdout) {
this.stdout.unpipe(process.stdout);
}
if (this.stderr) {
this.stderr.unpipe(process.stderr);
}
const running = this.exitCode === null;
if (running) {
return new Promise((resolve) => {
this.on("exit", resolve);
killTree(this.pid);
});
}
return Promise.resolve();
};
return child;
}Implementing killTree(pid)
Killing only the parent PID leaves grandchild processes orphaned. The killTree function discovers the full process tree, reverses it so children are killed first, then terminates each process:
javascript
import pidtree from "pidtree";
import treeKill from "tree-kill";
export async function killTree(pid) {
const descendants = await pidtree(pid, {
advanced: true,
root: true,
});
// Kill children before parents to avoid orphans
const pids = descendants.reverse().map((p) => p.pid);
await Promise.allSettled(
pids.map((pid) =>
new Promise((resolve) => {
treeKill(pid, "SIGTERM", resolve);
})
)
);
}Key details:
- Reverse order — Kill leaf processes first so parents don't respawn them.
Promise.allSettled— Some PIDs may already be gone. Don't let one failure abort the rest.SIGTERM— Gives processes a chance to clean up. Escalate toSIGKILLonly if they don't exit.
Implementing cleanupOnExit(fn)
The cleanupOnExit function registers a single cleanup callback that fires on all exit paths — normal exit, signals, uncaught exceptions, and unhandled rejections:
javascript
export function cleanupOnExit(cleanup) {
process.once("beforeExit", cleanup);
process.once("exit", cleanup);
process.once("SIGUSR1", cleanup);
process.once("SIGUSR2", cleanup);
process.once("SIGTERM", () => {
cleanup();
process.exit(0);
});
process.once("SIGINT", () => {
cleanup();
process.exit(0);
});
process.once("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
cleanup();
process.exit(1);
});
process.once("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection:", { promise, reason });
cleanup();
process.exit(1);
});
}Trade-offs
pidtree+tree-killare runtime dependencies. They shell out topson Unix. For simple cases where you know there are no grandchild processes,child.kill("SIGTERM")suffices.- Signal handlers override Node defaults. Registering
SIGINTandSIGTERMhandlers means Node won't exit automatically on those signals — you must callprocess.exit()yourself. exitevent handler must be synchronous. Theexitevent does not wait for async operations. If cleanup requires async work (like killing a process tree), usebeforeExitor signal handlers instead.oncevson. Useprocess.once()for cleanup handlers to avoid double-firing. A secondSIGINTduring cleanup would register a new handler if you usedon.