Comparison · vs PM2
RunWisp vs PM2: process supervision without the Node runtime.
A real ecosystem.config.js next to the equivalent runwisp.toml service. Replicas without Node cluster mode, restart backoff curves, scheduled jobs as their own kind, every restart as a row in history.
Should you switch?
The 10-second version. Skim, decide, scroll down for the receipts.
Switch to RunWisp if
- Your fleet isn't all Node; you'd rather not ship npm everywhere.
- You want every restart as a queryable run row, not a tail log.
- Scheduled jobs deserve their own kind, not
cron_restart. - Restart backoff should curve, not cap-and-stop.
- Graceful stop should kill the worker's children too, not just the parent.
- A Web UI / TUI without a paid hosted tier matters.
Stay on PM2 if
- Your app needs Node's shared-socket cluster mode today.
-
pm2 reloadrolling restarts are load-bearing for deploys. -
pm2 startupwriting an init script is real ergonomic win. - Native Windows hosting is non-negotiable.
- pm2.io's hosted dashboard with retained metrics is in use.
Yes / no, at a glance
Yes / no, both columns win on something.
| Feature | PM2 | RunWisp |
|---|---|---|
| Runtime-free single binary | needs Node | |
| Language-agnostic supervisor | ~ Node-flavoured | |
| Declarative config | JS module | validated TOML |
| Always-on services | | |
| Scheduled jobs | ~ cron_restart | [tasks.*] |
| Replicas as one config | | |
| Node cluster shared socket | | |
| Restart-backoff curve | ~ exp_backoff_restart_delay | |
| Duration-based stability reset | max_restarts cap | backoff_reset_after |
| Per-run history rows | | |
| Web UI in the binary | pm2.io is hosted | |
| TUI | pm2 monit | |
| Process-group-wide graceful stop | | |
| Coalesced failure notifications | | |
| Zero-downtime rolling reload | pm2 reload | |
| Auto-write init script | pm2 startup | |
| Native Windows | | Linux/macOS/WSL |
Pros & cons
What PM2 still does better
- Node
clustermode with a shared listening socket and master round-robin. -
pm2 reloadrolls workers one at a time; the listening port never drops. -
pm2 startupwrites systemd / launchd / init units for you. -
npm i -g pm2in a pure-Node shop is the smaller artifact. - Native Windows hosting today.
- pm2.io's hosted dashboard with retained metrics ships now (paid tier).
What RunWisp adds
- Single static binary;
run = "node app.js"works without Node on the host. - Declarative
runwisp.tomlvalidated up-front byrunwisp validate. -
instances = Nspawns N independent crash domains, not a master fan-out. -
[tasks.*]and[services.*]split at the schema; cron_restart isn't a substitute. - Restart backoff as a curve plus
backoff_reset_after; no terminal "errored" state. - Every replica start and exit is a row in the Web UI with exit code and captured streams.
- Graceful stop sends SIGTERM to the whole process group by default.
- Web UI and
runwisp tuiship in the binary; no paid tier gates.
The simple case
Keep one Node API process alive. Four JS lines vs one TOML stanza.
// ecosystem.config.js
module.exports = {
apps: [
{
name: "api",
script: "/srv/api/server.js",
},
],
}; # runwisp.toml
[services.api]
run = "node /srv/api/server.js" Production-grade
Three workers, hardened. cluster mode + max_memory_restart on the left; independent crash domains and named backoff on the right.
// ecosystem.config.js, three workers, production
module.exports = {
apps: [
{
name: "api-worker",
script: "./worker.js",
instances: 3,
exec_mode: "cluster",
min_uptime: "10s",
max_restarts: 10,
exp_backoff_restart_delay: 100, // ms
max_memory_restart: "512M",
kill_timeout: 20000, // SIGTERM grace, ms
out_file: "/var/log/api-worker.out.log",
error_file: "/var/log/api-worker.err.log",
env_production: {
NODE_ENV: "production",
QUEUE_URL: "redis://localhost:6379",
},
},
],
}; # runwisp.toml, three workers, production
[services.api-worker]
description = "Three Node workers consuming the same job queue"
instances = 3
restart_delay = "1s"
restart_backoff = "exponential"
backoff_reset_after = "2m" # how long "up" counts as stable
graceful_stop = "20s" # finish the in-flight job before SIGKILL
env = { NODE_ENV = "production", QUEUE_URL = "redis://localhost:6379" }
keep_runs = 500
notify_on_failure = ["slack-ops"]
run = """
trap 'echo "SIGTERM received, draining"; exit 0' TERM INT
node --max-old-space-size=512 ./worker.js
""" Migration cheat sheet
ecosystem.config.js field on the left, runwisp.toml field on the right.
| PM2 | RunWisp |
|---|---|
apps[].name | [services.<name>] or [tasks.<name>] |
script: | run = |
instances: 3 | instances = 3 |
exec_mode: "cluster" | N independent processes; bind with SO_REUSEPORT or proxy in front |
min_uptime + max_restarts | backoff_reset_after + restart_backoff |
exp_backoff_restart_delay | restart_backoff = "exponential" |
kill_timeout: 20000 | graceful_stop = "20s" |
max_memory_restart: "512M" | node --max-old-space-size=512 inside run |
env_production: { … } | env = { … } |
cron_restart | Model as [tasks.*] with cron = |
pm2 logs | Per-run log download from Web UI; runwisp tui |
pm2 reload | runwisp validate + daemon restart |
pm2 startup | Write a systemd / launchd unit yourself (docs/operations/autostart) |
Gotchas
instances is not Node cluster mode
instances = N spawns N independent processes. No master, no shared listening socket, no per-replica index in the environment. Code that branches on cluster.isPrimary won't recognise the topology. Bind with SO_REUSEPORT inside workers or front them with a reverse proxy.
No pm2 reload; validate first
Edits to runwisp.toml take effect only on daemon restart. No rolling per-service reload. runwisp validate --config <path> first; parse errors fail the boot before the daemon opens its port.
Logs are unified per service
Stdout / stderr from every replica land in the same per-service log, tagged by replica in the Web UI. PM2's per-app split (out_file / error_file with per-instance suffix) has no direct equivalent. If grep-by-replica matters on disk, declare N services with distinct names instead of instances = N.
No pm2 startup; wire the daemon yourself
PM2's pm2 startup writes an init script for you. RunWisp doesn't. Bring the daemon up under systemd, launchd, or the Docker entrypoint yourself; the operations/autostart docs page has the unit file.
FAQ
Yes. run = "node app.js" or whatever you would type at a shell. RunWisp spawns the command, captures stdout and stderr, watches the exit code, and writes a run row. The TOML is identical whether run is Node, Python, Go, or sh.
Not in the node:cluster sense. instances = N runs N independent processes. They don't share a listening socket and there is no master. If you need a shared port, bind with SO_REUSEPORT inside Node (18+) or front the workers with a reverse proxy.
Either let restart_backoff handle a clean periodic exit (node --max-old-space-size + V8 OOM, RunWisp brings the worker back), or model the work itself as a [tasks.*] with cron = and on_overlap = "skip".
Yes. They don't share state, sockets, or log paths unless you point them at the same files. Move one ecosystem.config.js entry at a time; roll back by reversing the same two steps.
Yes, twice. An embedded Web UI served by the daemon and runwisp tui that streams run history and live logs. Both ship in the binary; no separate process, no paid tier gates.
More comparisons: RunWisp vs cron · RunWisp vs supervisord · RunWisp vs systemd timers .
One binary supervising your processes. No Node runtime on the host.
Copy an ecosystem.config.js app into a services stanza, set instances, restart the daemon. Replicas come up indexed and visible, with every restart in run history.