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. - 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 | healthy_after |
| FATAL after repeated fast-fails | max_restarts → errored | start_retries |
| 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 | runwisp service install |
| 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. -
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
healthy_after;start_retriescaps fast-fails before a FATAL stop. - 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.
-
runwisp service installwrites the systemd / launchd unit for the daemon. - 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"
healthy_after = "2m" # how long "up" counts as stable
start_retries = 10 # fast-fail this many times, then go FATAL
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 | healthy_after |
max_restarts | start_retries (then FATAL) |
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 (rolling restart) | No zero-downtime rolling restart; runwisp reload re-reads config in place |
pm2 startup | runwisp service install (systemd / launchd) |
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 zero-downtime rolling reload
PM2's pm2 reload cycles workers one at a time so the listening port never drops — RunWisp has no equivalent. runwisp reload (or SIGHUP) re-reads runwisp.toml in place, validate-first, but a restarted service does drop its connections. If rolling deploys are load-bearing, keep PM2 or front the workers with a proxy.
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.
runwisp service install is the pm2 startup equivalent
Like PM2's pm2 startup, runwisp service install writes and enables the init unit for you — systemd or launchd, with companions service uninstall / service status and --print to emit the unit without installing. In a container, use the entrypoint instead.
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.