Process Management

Node.js Intermediate — Process Management

Node.js Intermediate

Chapter 8 of 8  ·  Process Management

Process Management

Node runs on a single thread and will exit the moment an unhandled error crashes it, or when you restart the server to deploy new code. Process management answers three questions: How do you keep the server running when it crashes? How do you use all the CPU cores on the machine? And how do you shut down cleanly without cutting off requests mid-flight? This chapter covers the tools and patterns that answer all three.

Unix Signals — The Language of Process Control

Signals are messages the OS sends to a running process. They're how Ctrl+C stops your server, how PM2 tells your app to restart, and how you trigger a graceful shutdown from a deployment pipeline.

SignalNumberDefault actionCatchable?Common use
SIGTERM 15 Terminate Yes Polite shutdown request — the standard signal for stopping a service gracefully
SIGINT 2 Terminate Yes Ctrl+C in the terminal — treat like SIGTERM in production code
SIGHUP 1 Terminate Yes Traditionally "terminal closed" — often repurposed for config reload without restart
SIGUSR1 10 Terminate Yes Reserved by Node for the debugger; PM2 uses it for graceful reload
SIGKILL 9 Kill immediately No Force-kill when SIGTERM is ignored — no cleanup possible
// Listening to signals in Node process.on('SIGTERM', () => { console.log('Received SIGTERM — shutting down gracefully'); // ... cleanup code ... }); process.on('SIGINT', () => { console.log('Received SIGINT (Ctrl+C)'); // treat the same as SIGTERM }); // Sending a signal from the terminal // kill -SIGTERM <pid> or kill -15 <pid> // kill -9 <pid> (SIGKILL — last resort)

Graceful Shutdown

An abrupt exit drops in-flight requests, half-written database rows, and open connections. Graceful shutdown means: stop accepting new work, finish what's already in progress, close resources, then exit.

const http = require('http'); const { pool } = require('./db'); const server = http.createServer(app); server.listen(3000, () => console.log('Listening on 3000')); let isShuttingDown = false; async function shutdown(signal) { if (isShuttingDown) return; isShuttingDown = true; console.log(`[${signal}] Graceful shutdown started`); // 1. Stop accepting new HTTP connections await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve()) ); console.log('HTTP server closed'); // 2. Close the database pool (waits for active queries to finish) await pool.end(); console.log('Database pool closed'); // 3. Exit cleanly process.exit(0); } // Safety net: force-kill if graceful shutdown takes too long function registerShutdown(signal) { process.on(signal, () => { const timer = setTimeout(() => { console.error('Shutdown timed out — forcing exit'); process.exit(1); }, 10_000).unref(); // .unref() so this timer won't keep the process alive on its own shutdown(signal).catch((err) => { console.error('Shutdown error:', err); process.exit(1); }).finally(() => clearTimeout(timer)); }); } registerShutdown('SIGTERM'); registerShutdown('SIGINT');

Handling Uncaught Errors

Two process-level events catch errors that escaped all try/catch and .catch() handlers. Without them, Node prints a warning (or crashes, depending on the version) and leaves your app in an unknown state.

// Synchronous throw that no one caught process.on('uncaughtException', (err, origin) => { console.error('Uncaught exception:', err); // The process is in an undefined state — log, then exit. // Do NOT try to continue serving requests. process.exit(1); }); // Promise rejection with no .catch() (Node 15+ exits by default) process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); process.exit(1); });

💡 Exit After uncaughtException — Always

After an uncaught exception, the process is in an unknown state: open file descriptors, partially executed callbacks, corrupted in-memory data. The only safe action is to log the error and exit. Let your process manager (PM2, systemd, Docker) restart the process fresh. Never swallow the error and continue.

The Cluster Module — Using All CPU Cores

Node is single-threaded, so a 16-core machine runs one Node process on one core by default. The built-in cluster module spawns one worker process per CPU, with the primary process distributing incoming connections across them.

Cluster architecture Primary process (cluster.isPrimary) │ listens on port 3000 │ forks workers, respawns on crash │ ├──► Worker 0 (cluster.isWorker) — handles requests ├──► Worker 1 (cluster.isWorker) — handles requests ├──► Worker 2 (cluster.isWorker) — handles requests └──► Worker 3 (cluster.isWorker) — handles requests OS distributes incoming connections round-robin across workers
const cluster = require('cluster'); const os = require('os'); const http = require('http'); const NUM_WORKERS = os.cpus().length; if (cluster.isPrimary) { console.log(`Primary ${process.pid} — forking ${NUM_WORKERS} workers`); for (let i = 0; i < NUM_WORKERS; i++) { cluster.fork(); } // Respawn a worker if it crashes cluster.on('exit', (worker, code, signal) => { console.warn(`Worker ${worker.process.pid} died (code=${code}, signal=${signal}) — respawning`); cluster.fork(); }); } else { // Each worker runs its own HTTP server — all share port 3000 http.createServer((req, res) => { res.end(`Handled by worker ${process.pid}`); }).listen(3000); console.log(`Worker ${process.pid} started`); }

💡 Cluster vs Worker Threads

Cluster spawns separate OS processes (like child processes from Chapter 3), each with their own memory — no shared state. This means session data, in-memory caches, or rate-limiter counters can't live in application memory; they need an external store (Redis, a database). Worker Threads (Chapter 4) share memory within one process and are suited to CPU tasks, not to handling HTTP connections across cores.

PM2 — Production Process Manager

Building cluster logic yourself works, but PM2 gives you restarts, clustering, log management, startup scripts, and monitoring out of the box with a single command.

# Install globally npm install -g pm2 # Start an app pm2 start server.js --name my-app # Start in cluster mode (one worker per CPU) pm2 start server.js --name my-app --instances max # Common commands pm2 list # show all running apps pm2 logs my-app # tail logs pm2 monit # real-time CPU/memory dashboard pm2 restart my-app # restart (brief downtime) pm2 reload my-app # zero-downtime reload (cluster mode only) pm2 stop my-app pm2 delete my-app # Survive server reboots — generates and installs a startup script pm2 startup pm2 save # save current process list so it's restored on boot

ecosystem.config.js — declarative PM2 configuration

module.exports = { apps: [{ name: 'my-app', script: './server.js', instances: 'max', // one per CPU core exec_mode: 'cluster', watch: false, // true = restart on file change (dev only) max_memory_restart: '500M', // restart if worker exceeds 500 MB env: { NODE_ENV: 'development', PORT: 3000, }, env_production: { NODE_ENV: 'production', PORT: 8080, }, error_file: './logs/error.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss', kill_timeout: 5000, // ms to wait for graceful shutdown before SIGKILL listen_timeout: 3000, // ms for a worker to be considered ready after (re)start }], };
# Deploy with the ecosystem file pm2 start ecosystem.config.js pm2 start ecosystem.config.js --env production # Zero-downtime deploy workflow git pull npm install --omit=dev pm2 reload ecosystem.config.js --env production

PM2 Zero-Downtime Reload

In cluster mode, pm2 reload restarts workers one at a time. Each worker receives SIGINT, finishes its in-flight requests, closes, and is replaced — leaving the other workers serving traffic throughout. There's no moment where the port is unbound.

// For pm2 reload to work gracefully, your app must handle SIGINT cleanly process.on('SIGINT', async () => { await shutdown('SIGINT'); // same shutdown function from earlier }); // Optionally signal PM2 that this worker is ready to receive traffic // (useful when startup involves async work like loading configs or warming caches) process.send?.('ready'); // process.send is only defined when running under PM2/cluster

⚠️ Common Process Management Mistakes

Not handling SIGTERM — without a handler, Node exits immediately when PM2 sends SIGTERM. In-flight requests are dropped, database connections are abandoned, and writes may be incomplete.

Sharing in-memory state in cluster mode — each worker is a separate process. A rate limiter, session store, or job queue living in JavaScript memory is invisible to the other workers. Use Redis or a database for shared state.

Using pm2 restart instead of pm2 reloadrestart stops all workers simultaneously (brief downtime); reload rotates them one by one (zero downtime). In production, always reload.

Catching uncaughtException and continuing — after an uncaught exception the process heap may be corrupted. Log and exit; let PM2 restart a fresh process immediately.

Coding Challenges

Challenge 1 — Graceful Shutdown with Timeout

Write a createGracefulShutdown(server, options) function that registers SIGTERM and SIGINT handlers. On signal: (1) stop accepting new connections, (2) wait for in-flight requests with a configurable drainMs timeout (default 10 s), (3) close a provided pool if given, (4) call an optional onShutdown async hook, then exit 0. If the drain timeout expires first, log a warning and exit 1. The function must be idempotent — receiving two signals doesn't run shutdown twice.

View sample solution ↗

Challenge 2 — Cluster with Health Reporting

Build a clustered HTTP server (one worker per CPU) where: (a) each worker responds to GET /health with its own PID and request count, (b) the primary process tracks how many times each worker has been respawned and refuses to respawn a worker that has crashed more than 5 times in 60 seconds (remove it instead and log a warning), and (c) workers send their request count to the primary every 5 seconds via IPC so the primary can log aggregate stats.

View sample solution ↗

Challenge 3 — SIGHUP Config Reload

Write a server that loads its configuration from a JSON file at startup. When it receives SIGHUP, it re-reads the file and applies any changed values (port changes are ignored at runtime — log a notice instead). The config reload must be atomic: the old config stays active until the new one is fully parsed and validated. If the new file has invalid JSON or fails validation, log the error and keep the old config. Demonstrate by changing a value in the file and sending kill -HUP <pid>.

View sample solution ↗