Skip to content

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:

  1. child.terminate() — Unpipe stdio streams, then kill the entire process tree.
  2. killTree(pid) — Recursively find all descendant PIDs and kill them child-first.
  3. 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 to SIGKILL only 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-kill are runtime dependencies. They shell out to ps on Unix. For simple cases where you know there are no grandchild processes, child.kill("SIGTERM") suffices.
  • Signal handlers override Node defaults. Registering SIGINT and SIGTERM handlers means Node won't exit automatically on those signals — you must call process.exit() yourself.
  • exit event handler must be synchronous. The exit event does not wait for async operations. If cleanup requires async work (like killing a process tree), use beforeExit or signal handlers instead.
  • once vs on. Use process.once() for cleanup handlers to avoid double-firing. A second SIGINT during cleanup would register a new handler if you used on.