Skip to main content

pm2 vs node:cluster vs tsx watch: Node.js Process Management (2026)

·PkgPulse Team

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.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

Featurepm2node:clustertsx watch
EnvironmentProductionProductionDevelopment
Clustering✅ (automatic)✅ (manual)
Auto-restart on crashManual
Zero-downtime reload✅ (pm2 reload)Manual
File watching
Log management
Memory limit restart
Monitoring dashboard
Startup on bootManual (systemd)
DependenciesExternalBuilt-inDev dependency
Weekly downloads~3Mbuilt-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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.