pm2 vs node:cluster vs tsx watch: Node.js Process Management (2026)
TL;DR
pm2 is the production process manager for Node.js — handles clustering, auto-restart on crash, log rotation, zero-downtime reload, and monitoring dashboard. node:cluster is Node.js's built-in module — forks your app across CPU cores manually, you build the restart logic yourself. tsx watch (or node --watch) is for development only — auto-restarts on file changes during development. In 2026: pm2 for production deployments on VPS/VMs, node:cluster if you want zero dependencies, and tsx watch/node --watch for development.
Key Takeaways
- pm2: ~3M weekly downloads — production-grade, clustering + restart + logs + monitoring in one tool
- node:cluster: built-in — manual clustering across CPU cores, no external dependency
- tsx watch / node --watch: development only — file-change detection, auto-restart
- pm2 zero-downtime reload (
pm2 reload) — restarts workers one at a time, never drops requests - node:cluster gives you full control but requires you to handle worker death, signal forwarding, and graceful shutdown manually
- In Docker/Kubernetes: you often DON'T need pm2 — the orchestrator handles restarts and scaling
When Process Management Matters
Development:
Need: auto-restart on file changes
Solution: tsx watch, node --watch, or nodemon
Production (VPS / bare metal):
Need: crash recovery, clustering, log management
Solution: pm2, systemd + node:cluster
Production (Docker / Kubernetes):
Need: single process per container
Solution: node dist/index.js (orchestrator handles restart)
PM2 is unnecessary — Kubernetes restarts crashed pods automatically
pm2
pm2 — production process manager:
Quick start
npm install -g pm2
# Start in cluster mode (all CPU cores):
pm2 start dist/index.js -i max --name "pkgpulse-api"
# Or with specific number of instances:
pm2 start dist/index.js -i 4 --name "pkgpulse-api"
Ecosystem file (recommended)
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: "pkgpulse-api",
script: "dist/index.js",
instances: "max", // Use all CPU cores
exec_mode: "cluster", // Cluster mode (vs fork)
max_memory_restart: "500M", // Restart if memory exceeds 500MB
env: {
NODE_ENV: "production",
PORT: 3000,
},
// Log management:
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "logs/error.log",
out_file: "logs/output.log",
merge_logs: true,
// Auto-restart:
watch: false, // Don't watch files in production
autorestart: true, // Restart on crash
max_restarts: 10, // Max restart count in restart_delay window
restart_delay: 4000, // Wait 4s between restarts
},
{
name: "pkgpulse-worker",
script: "dist/worker.js",
instances: 2,
exec_mode: "cluster",
cron_restart: "0 */6 * * *", // Restart every 6 hours (memory leak protection)
},
],
}
# Start from ecosystem file:
pm2 start ecosystem.config.cjs
# Zero-downtime reload (restarts workers one at a time):
pm2 reload pkgpulse-api
# Stop:
pm2 stop pkgpulse-api
# Delete from pm2 process list:
pm2 delete pkgpulse-api
Zero-downtime reload
# Standard restart — kills all, starts all (brief downtime):
pm2 restart pkgpulse-api
# Graceful reload — one worker at a time (zero downtime):
pm2 reload pkgpulse-api
# How it works:
# 1. pm2 sends SIGINT to worker 1
# 2. Worker 1 finishes in-flight requests
# 3. Worker 1 exits, pm2 starts replacement worker 1'
# 4. Worker 1' is ready → pm2 sends SIGINT to worker 2
# 5. Repeat until all workers are replaced
# → At NO point are zero workers running
Graceful shutdown in your app
// Your app must handle SIGINT for graceful reload:
import { createServer } from "node:http"
const server = createServer(app)
server.listen(3000)
process.on("SIGINT", () => {
console.log("Received SIGINT, shutting down gracefully...")
server.close(() => {
// Close database connections:
db.end()
process.exit(0)
})
// Force exit after 10 seconds:
setTimeout(() => process.exit(1), 10_000)
})
Monitoring
# Real-time dashboard:
pm2 monit
# Process list:
pm2 list
# Detailed info:
pm2 show pkgpulse-api
# Logs:
pm2 logs pkgpulse-api --lines 100
# Startup script (survive reboots):
pm2 startup # Generates systemd/launchd script
pm2 save # Saves current process list
node:cluster
node:cluster — built-in clustering:
Basic clustering
import cluster from "node:cluster"
import { availableParallelism } from "node:os"
import { createServer } from "node:http"
import { app } from "./app.js"
const numCPUs = availableParallelism()
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} starting ${numCPUs} workers`)
// Fork workers:
for (let i = 0; i < numCPUs; i++) {
cluster.fork()
}
// Restart crashed workers:
cluster.on("exit", (worker, code, signal) => {
console.warn(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`)
cluster.fork()
})
} else {
// Workers share the same TCP port:
const server = createServer(app)
server.listen(3000, () => {
console.log(`Worker ${process.pid} listening on :3000`)
})
}
Zero-downtime reload (manual)
import cluster from "node:cluster"
if (cluster.isPrimary) {
// Graceful reload — restart workers one at a time:
process.on("SIGUSR2", async () => {
const workers = Object.values(cluster.workers!)
for (const worker of workers) {
if (!worker) continue
// Start new worker:
const replacement = cluster.fork()
// Wait for replacement to be ready:
await new Promise<void>((resolve) => {
replacement.on("listening", () => resolve())
})
// Disconnect old worker (finish in-flight requests):
worker.disconnect()
// Wait for old worker to exit:
await new Promise<void>((resolve) => {
worker.on("exit", () => resolve())
})
console.log(`Replaced worker ${worker.process.pid} → ${replacement.process.pid}`)
}
})
}
// Trigger reload:
// kill -USR2 <primary-pid>
IPC between primary and workers
if (cluster.isPrimary) {
// Broadcast to all workers:
function broadcast(message: unknown) {
for (const worker of Object.values(cluster.workers!)) {
worker?.send(message)
}
}
// Listen for worker messages:
cluster.on("message", (worker, message) => {
console.log(`Worker ${worker.process.pid}: ${JSON.stringify(message)}`)
if (message.type === "cache-invalidate") {
broadcast({ type: "cache-invalidate", key: message.key })
}
})
} else {
// Worker sends message to primary:
process.send?.({ type: "cache-invalidate", key: "packages:react" })
// Worker receives broadcast:
process.on("message", (message) => {
if (message.type === "cache-invalidate") {
cache.delete(message.key)
}
})
}
tsx watch / node --watch (Development)
node --watch (built-in, Node.js 18+)
# Built-in file watcher — no dependencies:
node --watch dist/index.js
# With specific glob:
node --watch-path=src dist/index.js
# Preserve console output:
node --watch-preserve-output dist/index.js
tsx watch (TypeScript)
npm install -D tsx
# Watch and restart on file changes:
tsx watch src/index.ts
# With custom ignore patterns:
tsx watch --ignore node_modules --ignore dist src/index.ts
# In package.json:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
}
}
Feature Comparison
| Feature | pm2 | node:cluster | tsx watch |
|---|---|---|---|
| Environment | Production | Production | Development |
| Clustering | ✅ (automatic) | ✅ (manual) | ❌ |
| Auto-restart on crash | ✅ | Manual | ❌ |
| Zero-downtime reload | ✅ (pm2 reload) | Manual | ❌ |
| File watching | ✅ | ❌ | ✅ |
| Log management | ✅ | ❌ | ❌ |
| Memory limit restart | ✅ | ❌ | ❌ |
| Monitoring dashboard | ✅ | ❌ | ❌ |
| Startup on boot | ✅ | Manual (systemd) | ❌ |
| Dependencies | External | Built-in | Dev dependency |
| Weekly downloads | ~3M | built-in | ~5M |
When to Use Each
Choose pm2 if:
- Deploying on VPS, bare metal, or EC2 instances
- Need clustering + auto-restart + log management in one tool
- Want zero-downtime deployments with
pm2 reload - Running multiple apps on the same server
Choose node:cluster if:
- Want zero external dependencies — pure Node.js
- Need fine-grained control over worker lifecycle and IPC
- Building custom process management (job queue workers, etc.)
- Already using systemd for process supervision
Choose tsx watch / node --watch if:
- Local development with auto-restart on file changes
- tsx watch for TypeScript, node --watch for JavaScript
- Never use in production
Skip all of these if:
- Using Docker + Kubernetes — k8s handles restart, scaling, and health checks
- Using serverless (Vercel, Lambda) — no long-running process to manage
- Run ONE process per container, let the orchestrator handle the rest
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on pm2 v5.x, Node.js 22 cluster module, and tsx v4.x.
Compare process management and developer tooling on PkgPulse →