← all posts

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.

4×every job ran under pm2 -i 4, in the same millisecond
0PM2 settings that run a job on one worker only
<1sfor PM2 to replace a killed worker 0 in our test

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 boxOnce across serversRun historyNo 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.js in 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_var renames it. If your ecosystem file sets instance_var: "INSTANCE_ID", NODE_APP_INSTANCE is 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 .runwisp on 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_INSTANCE check, with the ?? "0" default.
  • One box, jobs heavy enough to slow requests: a separate fork-mode PM2 app.
  • Several servers, plain node-cron: distributed: true with 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/schedule code 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?
Cluster mode starts one Node.js process per instance, and each one runs your code from the top. Every process that reaches cron.schedule() starts its own scheduler with its own timers, and they don't know about each other. With pm2 start app.js -i 4 each job runs four times per tick; with -i max it runs once per CPU core, so the count changes between a 2-core staging box and an 8-core production one. Docker replicas and Kubernetes pods do the same thing.
Does PM2 have a setting to run a cron job on only one instance?
No. PM2's cron_restart option restarts the whole app on a schedule, which is a different thing: in cluster mode it restarts every worker. To run a job once you either check NODE_APP_INSTANCE, which PM2 sets to 0, 1, 2 and so on in each worker, run the scheduler as a separate PM2 app with instances: 1 and exec_mode: fork, or coordinate through something shared like a Redis lock.
Is checking NODE_APP_INSTANCE === '0' safe?
On one machine under PM2, mostly. PM2 had a new worker 0 ticking within a second of a kill -9 in our test and the every-second job didn't miss a tick. The traps: NODE_APP_INSTANCE is undefined when you run the app with plain node, so the job never runs in development unless you default it to 0; renaming it with PM2's instance_var changes the variable; every server and every container has its own worker 0, so two hosts still run the job twice; and if worker 0 hits PM2's restart limit, the jobs stop while the other workers keep serving.
How do I stop @nestjs/schedule cron jobs running on every instance?
@nestjs/schedule has no lock or leader election, so each instance of the app runs every @Cron method. On one PM2 box, pass disabled: (process.env.NODE_APP_INSTANCE ?? '0') !== '0' in the @Cron options, or boot a second entry point that imports ScheduleModule and run it as a single fork-mode PM2 app. Across several servers, take a Redis lock per run inside the method with SET key NX PX, or move the jobs to BullMQ, whose job schedulers produce one job per tick however many instances register them.
What does node-cron's distributed option do?
Added in node-cron 4.4, distributed: true asks a run coordinator whether this instance should run each fire. The built-in one reads NODE_CRON_RUN, which must be 'true' on exactly one instance and 'false' on the rest; anything else throws when the task is scheduled. The @node-cron/redis-coordinator package replaces it with a Redis lock per fire, so any instance can win and a dead one doesn't stop the job. Set distributedLease longer than the job's longest run.
Can I keep my node-cron or @nestjs/schedule code and run each job once per server?
Yes, with RunWisp, an open-source cron replacement that ships as a single binary. Its npm package @runwisp/js has node-cron 4's API and @nestjs/schedule's decorators, so you change the import. PM2 workers that share a data directory share one RunWisp: one worker runs the jobs, the others wait and one takes over when it exits. Every run is recorded with its output, exit code and duration, and a failure can alert you. Its limits: it's per machine, so two servers still each run every job and need Redis or BullMQ; it starts a daemon next to your app; and it's a scheduler, not a job queue.
  1. 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.

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

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh