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.