Skip to content

How to Run Cron Jobs Across Multiple Servers ​

You deploy three replicas of your Node.js app behind a load balancer. All three have the same code, the same schedule, and the same cron job. At 3 a.m., the nightly backup runs three times instead of once.

This happens in every multi-instance setup: PM2 cluster mode, Kubernetes Deployments, blue/green rollouts, auto-scaling groups. The scheduler doesn't know about other copies of itself.

The problem ​

instance A: 0 3 * * * → runs backup
instance B: 0 3 * * * → runs backup (duplicate)
instance C: 0 3 * * * → runs backup (duplicate)

The solution: distributed: true ​

node-cron has built-in distributed coordination. Set distributed: true and only one instance executes each scheduled fire:

js
import cron from 'node-cron';

cron.schedule('0 3 * * *', runNightlyBackup, {
  name: 'nightly-backup',
  distributed: true,
});
instance A: 0 3 * * * → runs backup ✓
instance B: 0 3 * * * → skipped (not elected)
instance C: 0 3 * * * → skipped (not elected)

Two things are required:

  • A name for the task (it forms the coordination key shared across instances)
  • A way to decide which instance runs: the default uses an env-var flag, or you can plug in a Redis coordinator for high availability

Option 1: Designated runner (env-var) ​

The simplest setup. Designate one instance as the runner:

bash
# instance A (the runner)
NODE_CRON_RUN=true node app.js

# instances B, C, D
NODE_CRON_RUN=false node app.js

This works well when your orchestrator already decides which pod is special: a StatefulSet, a single-replica Deployment, a dedicated worker dyno.

If NODE_CRON_RUN is unset, node-cron throws at schedule time (on startup, not silently at 3 a.m.), so a misconfigured deploy fails loudly.

Trade-off

The env-var default is a single designated runner. If instance A is down at 3 a.m., the backup doesn't run. For high availability, use a coordinator.

Option 2: Redis coordinator (high availability) ​

With a Redis coordinator, every replica races for each fire, exactly one wins, and if the winner is down another takes over:

bash
npm install @node-cron/redis-coordinator
js
import { createClient } from 'redis';
import cron, { setRunCoordinator } from 'node-cron';
import { RedisLockCoordinator } from '@node-cron/redis-coordinator';

const redis = createClient();
await redis.connect();

setRunCoordinator(new RedisLockCoordinator(redis));

cron.schedule('0 3 * * *', runNightlyBackup, {
  name: 'nightly-backup',
  distributed: true,
  distributedLease: 5 * 60_000,
});

No special instance needed. Works with both ioredis and node-redis v4.

Knowing when an instance skips ​

When an instance is not elected to run, it emits execution:skipped:

js
task.on('execution:skipped', (ctx) => {
  if (ctx.reason === 'coordinator-error') {
    alert('coordination backend is down', ctx);
  }
});
ReasonMeaning
not-electedHealthy. Another instance ran this fire.
coordinator-errorThe coordinator failed (e.g. Redis down). The fire may not have run anywhere. Alert on this.

Custom coordinators ​

The Redis coordinator is one implementation of the RunCoordinator interface. You can back coordination with anything your fleet shares:

ts
interface RunCoordinator {
  shouldRun(key: string, ttlMs: number): boolean | Promise<boolean>;
  onComplete?(key: string): void | Promise<void>;
}

Postgres, etcd, DynamoDB, or any distributed lock mechanism works.

Combining with other features ​

With overlap prevention ​

js
cron.schedule('0 * * * *', syncInventory, {
  name: 'sync-inventory',
  distributed: true,
  noOverlap: true,
});

One instance runs each fire, and within that instance, overlapping runs are prevented.

With background tasks ​

js
setRunCoordinator(new RedisLockCoordinator(redis));

cron.schedule('0 3 * * *', './tasks/backup.js', {
  name: 'nightly-backup',
  distributed: true,
});

The forked daemon asks the parent over IPC, and the parent runs the coordinator. No extra configuration.

When distributed coordination is not enough ​

node-cron's distributed mode guarantees no concurrent execution across instances when clocks are in sync. It is not a hard exactly-once: under a crash-and-retry or large clock skew, a fire could run more than once. Treat distributed tasks as idempotent.

For stronger guarantees:

Next steps ​

Released in 2016 under the ISC License.