node-cron runs multiple times in PM2 cluster mode (and @nestjs/schedule too): 7 fixes
Under pm2 -i 4, every node-cron or @nestjs/schedule job runs four times. Why, and seven ways to make it run once: the NODE_APP_INSTANCE guard, node-cron's distributed option, a fork-mode scheduler, Redis locks, BullMQ and more.
11 min read
You switched PM2 to cluster mode, pm2 start app.js -i max, because the API was CPU-bound. The API
got faster. The next morning every customer got the daily digest email four times, and the
“send-digest ran” line in the logs shows up four times at exactly 08:00:00.
Nothing is broken, which is the annoying part. Each PM2 worker is its own Node.js process, and each
one started its own copy of every cron job. node-cron and @nestjs/schedule don’t coordinate between
processes, and PM2 has no setting that does it for them.
Short answer
PM2 cluster mode runs one process per instance, and a scheduler that lives in the web process runs
once per process. On a single PM2 host, the cheapest fixes are a NODE_APP_INSTANCE === "0" check
around the scheduling code, or moving the schedule into a second PM2 app with instances: 1 and
exec_mode: "fork". node-cron 4.4+ also has distributed: true for the same job. Across several
servers you need something shared: a Redis lock per run, node-cron’s Redis coordinator, or BullMQ.
@nestjs/schedule has no built-in fix, so the same options apply to NestJS.
If you’d like to keep your node-cron or @nestjs/schedule code as it is and see every run, RunWisp,
an open-source cron replacement that ships as a single binary, has an npm package that runs each
job once per machine however many workers you start. Setup below.
| Once per box | Once across servers | Run history | No new infra | |
|---|---|---|---|---|
NODE_APP_INSTANCE check | ✓ | ✗ | ✗ | ✓ |
node-cron distributed (env var) | ✓ | ✓ | ✗ | ✓ |
node-cron distributed (Redis) | ✓ | ✓ | ✗ | ✗ |
| Separate fork-mode PM2 app | ✓ | ✗ | ✗ | ✓ |
| Redis lock in the job | ✓ | ✓ | ✗ | ✗ |
| BullMQ job scheduler | ✓ | ✓ | ✓ | ✗ |
| Host cron or systemd timer | ✓ | ✗ | ✗ | ✓ |
RunWisp @runwisp/js | ✓ | ✗ | ✓ | ✗ |
“Once across servers” for the env-var coordinator means you set NODE_CRON_RUN=true on one server
by hand. Host cron counts as ✗ for the same reason: it’s one box’s crontab, and you have to remember
which box.
Why every worker runs the job
pm2 start app.js -i 4 starts four Node.js processes through Node’s cluster module. They share the
listening socket, so incoming requests are spread across them, but nothing else is shared. Each
process loads app.js from the top, reaches cron.schedule(), and starts its own timer.
We ran this on PM2 7.1.1 with node-cron 4.6.0 and a five-second job:
// app.js
import http from "node:http";
import cron from "node-cron";
cron.schedule(
"*/5 * * * * *",
() => {
const at = new Date().toISOString().slice(11, 23);
console.log(
`digest sent at ${at} pid=${process.pid} instance=${process.env.NODE_APP_INSTANCE}`,
);
},
{ name: "send-digest" },
);
http.createServer((_, res) => res.end("ok")).listen(3000);
$ pm2 start app.js -i 4
digest sent at 17:01:00.001 pid=224521 instance=0
digest sent at 17:01:00.001 pid=224528 instance=1
digest sent at 17:01:00.001 pid=224535 instance=2
digest sent at 17:01:00.001 pid=224548 instance=3
digest sent at 17:01:05.001 pid=224521 instance=0
digest sent at 17:01:05.001 pid=224528 instance=1
...
@nestjs/schedule 12.0.2 on NestJS 12.2 did exactly the same: one @Cron method, four runs per
tick, one per worker. The count follows the instance count, so with -i max a job that ran twice on
the 2-core staging box runs eight times on the 8-core production one. Docker replicas, Kubernetes
pods and a blue-green deploy where old and new containers overlap all do the same thing.
PM2’s cron_restart option sounds like it should help and doesn’t. It restarts the app on a
schedule (every worker, in cluster mode) and runs no job.
1. Only schedule on worker 0
PM2 sets NODE_APP_INSTANCE to 0, 1, 2 and so on in each worker. Schedule only in the first:
// ?? "0": under plain `node app.js` the variable is unset, and the job should still run
if ((process.env.NODE_APP_INSTANCE ?? "0") === "0") {
cron.schedule("0 8 * * *", sendDigest, { name: "send-digest", timezone: "UTC" });
}
In NestJS, @Cron takes a disabled option, so the decorator stays where it is:
@Cron("0 8 * * *", {
name: "send-digest",
timeZone: "UTC",
disabled: (process.env.NODE_APP_INSTANCE ?? "0") !== "0",
})
async sendDigest() { /* … */ }
This is the most common fix, and it holds up better than its reputation. We ran the guarded app
every second, killed worker 0 with kill -9, and PM2 had a new worker 0 ticking within a second; no tick was
missed. A pm2 reload didn’t double or drop a tick either.
Where it breaks:
- The
?? "0"matters. Without it,node app.jsin development never schedules anything, and you go looking for a cron bug that isn’t there. - Every server has a worker 0, and so does every container running
pm2-runtime. Two hosts still run each job twice. instance_varrenames it. If your ecosystem file setsinstance_var: "INSTANCE_ID",NODE_APP_INSTANCEis gone.- Worker 0 can stay down. If it crashes on startup often enough to hit PM2’s
max_restarts, PM2 marks it errored. The other workers keep serving requests, so nothing looks wrong, and no job runs. - Worker 0 does all the work. A heavy job slows the requests that land on that worker.
2. node-cron’s distributed option
node-cron 4.4 added distributed: true. Each fire, the task asks a run coordinator whether this
instance should run it. The built-in coordinator reads NODE_CRON_RUN, which must be 'true' on
one instance and 'false' on the others. Under PM2 you can derive it from the worker number:
process.env.NODE_CRON_RUN ??= String((process.env.NODE_APP_INSTANCE ?? "0") === "0");
cron.schedule("0 8 * * *", sendDigest, { name: "send-digest", timezone: "UTC", distributed: true });
On one box that’s fix 1 with an official name, and it fails louder: with NODE_CRON_RUN unset or set
to 1, schedule() throws “a distributed task needs NODE_CRON_RUN set to ‘true’ or ‘false’”
instead of running everywhere. Across servers, set NODE_CRON_RUN=true on one host and false on
the rest.
The useful part is that the coordinator is pluggable. @node-cron/redis-coordinator (needs node-cron
4.4.1+) takes a Redis lock per fire, so every instance races for each run, one wins, and a dead
instance doesn’t stop the job:
import Redis from "ioredis";
import cron from "node-cron";
import { RedisLockCoordinator } from "@node-cron/redis-coordinator";
cron.setRunCoordinator(new RedisLockCoordinator(new Redis(process.env.REDIS_URL)));
cron.schedule("0 3 * * *", runNightlyBackup, {
name: "nightly-backup",
distributed: true,
distributedLease: 15 * 60_000, // longer than the backup can take
});
Keep distributedLease above the job’s worst-case run time. If the lease runs out mid-run, another
instance can start a second copy. The coordinator is node-cron only; NestJS users are on their own
(see fix 4).
3. Run the scheduler as its own fork-mode app
Take the schedule out of the web process entirely and give it its own PM2 entry:
// ecosystem.config.js
module.exports = {
apps: [
{ name: "web", script: "dist/server.js", instances: "max", exec_mode: "cluster" },
{ name: "scheduler", script: "dist/scheduler.js", instances: 1, exec_mode: "fork" },
],
};
dist/scheduler.js imports your job functions and calls cron.schedule(); server.js doesn’t import
it. In NestJS, the scheduler entry boots a module with ScheduleModule.forRoot() through
NestFactory.createApplicationContext(), with no HTTP server, and the web AppModule stops importing
ScheduleModule. If both modules end up importing it, you’re back to one scheduler per worker plus
one.
This is the cleanest fix with no new dependencies. Jobs no longer slow down request handling, you
can restart the scheduler without touching the web workers, and pm2 logs scheduler shows only job
output. It’s still one box, though, and still no run history beyond the log file.
4. A Redis lock inside the job
When the app runs on several servers and you already have Redis, let every instance fire and have each run take a lock first. The key includes the scheduled time, so each fire gets its own lock:
@Cron("0 8 * * *", { name: "send-digest", timeZone: "UTC" })
async sendDigest() {
// one key per fire: send-digest:2026-10-27T08:00
const key = `lock:send-digest:${new Date().toISOString().slice(0, 16)}`;
const won = await this.redis.set(key, String(process.pid), "PX", 10 * 60_000, "NX");
if (!won) return; // another instance has this fire
await this.digest.send();
}
SET … NX only succeeds for the first caller, and PX expires the key so it doesn’t pile up. Don’t
delete the key when the job finishes: a server whose clock is a second behind would then take the
same fire and run it again. This doesn’t stop a slow run from overlapping the next fire; preventing
overlapping runs has lock patterns for that.
5. BullMQ job schedulers
If the jobs already belong in a queue, or you want retries and a record of what ran, BullMQ does this natively. A job scheduler lives in Redis under an ID, so registering it from every worker updates the same one, and each tick produces one job that one worker takes:
import { Queue, Worker } from "bullmq";
const connection = { url: process.env.REDIS_URL }; // BullMQ 6 + `npm i ioredis`
const queue = new Queue("cron", { connection });
// safe to run in every worker: same ID, one scheduler
await queue.upsertJobScheduler(
"send-digest",
{ pattern: "0 8 * * *", tz: "UTC" },
{ name: "send-digest", opts: { attempts: 3, backoff: { type: "exponential", delay: 30_000 } } },
);
new Worker(
"cron",
async (job) => {
if (job.name === "send-digest") await sendDigest();
},
{ connection },
);
For NestJS, @nestjs/bullmq wraps the same thing in decorators. You get retries with backoff,
completed and failed jobs kept in Redis, and dashboards like Bull Board. You also take on Redis as a
dependency, set to maxmemory-policy noeviction as BullMQ requires. That’s a lot if all you had was
one nightly email.
6. Move the schedule out of Node
The job doesn’t have to be scheduled by the app at all. Make it a script with its own entry point and let the host schedule it:
# m h dom mon dow
0 8 * * * cd /srv/app && flock -n /tmp/send-digest.lock node dist/jobs/send-digest.js >> /var/log/app/send-digest.log 2>&1
The web workers go back to serving requests, PM2’s instance count stops mattering, and flock keeps
a slow run from overlapping the next one. It’s the boring answer and often the right one. The costs
are the usual cron ones: it doesn’t load your .env, the timezone is the server’s, and a failure is
silent until you add logging and alerts. If
the job has to run inside the app, curl an internal endpoint instead, as in the Vercel Cron
post. RunWisp vs PM2 covers PM2 as a scheduler
in more depth.
7. Swap the import for RunWisp
RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single
binary, with a web UI that records every run. Its npm package, @runwisp/js, has node-cron 4’s API,
so the fix is the import:
npm install @runwisp/js
// import cron from "node-cron";
import { RunWisp } from "@runwisp/js";
const cron = new RunWisp();
cron.schedule("0 8 * * *", sendDigest, { name: "send-digest", timezone: "UTC" });
The first worker starts RunWisp next to the app. Workers that share a data directory (by default
./.runwisp, so every PM2 worker on the box) share that one RunWisp: one worker runs the jobs, the
others wait. We ran the five-second job from above under pm2 start -i 4 with @runwisp/js 1.8.0,
then killed the active worker:
[worker 1] runwisp: standby: another copy of this app is active (pid 229117); this one runs the tasks once that one stops
[worker 2] runwisp: standby: another copy of this app is active (pid 229117); this one runs the tasks once that one stops
[worker 3] runwisp: standby: another copy of this app is active (pid 229117); this one runs the tasks once that one stops
[worker 0] digest sent at 17:02:45.001 pid=229117 instance=0
[worker 0] digest sent at 17:02:50.001 pid=229117 instance=0
# kill -9 229117 at 17:02:53.214
[worker 1] runwisp: this copy of the app is now active and runs the tasks
[worker 1] digest sent at 17:02:55.002 pid=229126 instance=1
[worker 1] digest sent at 17:03:00.002 pid=229126 instance=1
Once per tick, and the next tick ran on worker 1 without waiting for PM2 to bring worker 0 back. The callbacks still run inside your app with its imports and connections; RunWisp does the scheduling and records each run with its output, exit code and duration. A thrown error fails the run, and you can get alerts on Slack, Discord, Telegram, email or a webhook.
For NestJS, change @nestjs/schedule to @runwisp/js/nestjs in your imports. ScheduleModule is
an alias there, so ScheduleModule.forRoot(), @Cron, @Interval and @Timeout stay as they are.
The same Nest app under -i 4 ran each tick once, with three workers on standby. Two differences
from @nestjs/schedule: @Interval must be a whole number of seconds, and addCronJob() isn’t
supported (inject RunWisp and call task() instead). Call app.enableShutdownHooks() so a
running job finishes before the worker exits.
node-cron’s distributed options are ignored with a warning, since RunWisp already runs each job
once per data directory. The node-cron and
NestJS guides list what else changes.
The limits:
- It’s per machine. Two servers, or two containers with their own data directories, each run every job. For that you want fix 2’s Redis coordinator, fix 4 or BullMQ. RunWisp doesn’t cluster.
- It’s a daemon your app starts, with a port (
9477) and a data directory to keep. In Docker, put.runwispon a volume or the history goes with the container. - It’s not a queue. No fan-out, no per-item retries from code. If that’s what you need, BullMQ.
Which one to use
- One box, one or two idempotent jobs: the
NODE_APP_INSTANCEcheck, with the?? "0"default. - One box, jobs heavy enough to slow requests: a separate fork-mode PM2 app.
- Several servers, plain node-cron:
distributed: truewith the Redis coordinator. - Several servers, NestJS: a Redis lock per fire, or BullMQ if you want retries too.
- The job doesn’t need the app’s process: a crontab line with
flock. - One box, and you want to keep your node-cron or
@nestjs/schedulecode and see every run: RunWisp. The quick start covers the standalone daemon, and the code is on GitHub.
Common questions
Why does node-cron run multiple times in PM2 cluster mode?
Does PM2 have a setting to run a cron job on only one instance?
Is checking NODE_APP_INSTANCE === '0' safe?
How do I stop @nestjs/schedule cron jobs running on every instance?
What does node-cron's distributed option do?
Can I keep my node-cron or @nestjs/schedule code and run each job once per server?
Read next
-
Django periodic tasks without Celery or Redis: 5 Docker Compose setups
You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.
-
Laravel scheduler in Docker: schedule:run with cron, schedule:work or Supervisor?
Four ways to run Laravel's scheduler in a container, tested on Laravel 12 and 13: which see your env vars, where the output goes, what docker stop does to a running task, and why schedule:run exits 0 when a task fails.