Skip to content

Distributed Coordination

The moment you run more than one copy of your app, cron gets awkward. Three replicas behind a load balancer, a PM2 cluster, a Kubernetes Deployment scaled to 4 pods, a blue/green rollout with both colors live for a minute, all of them have the same code, so all of them schedule the same job, and the nightly backup runs four times instead of once.

distributed: true solves that: the task fires on exactly one instance per scheduled time, across the whole fleet.

js
import cron from 'node-cron';

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

Two things are required and one is the question that drives everything else:

  • It is opt-in per task (distributed: true); other tasks keep running everywhere.
  • It needs a name (it forms the coordination key shared across instances; the auto-generated id is per-process and can't coordinate).
  • And it asks one question on every fire: "should this instance run this time?" The thing that answers is the run coordinator.

The default: one designated runner

Out of the box, node-cron answers that question with an environment variable, NODE_CRON_RUN, no extra dependencies, no Redis. You designate one instance as the runner:

bash
# instance A
NODE_CRON_RUN=true   node app.js

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

Now the backup runs only on instance A; B, C, and D skip it. This is the simplest correct answer to "stop running my cron N times," and for many fleets it's all you need: your orchestrator already decides which pod is special (a StatefulSet, a single-replica Deployment, a dedicated worker dyno), so let it set the flag.

There is no default value. If a distributed task is scheduled and NODE_CRON_RUN is unset (or isn't exactly 'true'/'false'), node-cron throws at schedule time, on startup, not silently at 3 a.m.:

node-cron: a `distributed` task needs NODE_CRON_RUN set to 'true' or 'false'.
Set it to 'true' on exactly one instance and 'false' on the others, or provide
a coordinator via cron.setRunCoordinator(...).

This is deliberate. A silent default could only do one of two wrong things, run everywhere (the duplicates you came here to fix) or run nowhere (a backup that quietly never happens). Failing loudly on deploy is the safe choice.

This is not high availability

The env-var default is a single designated runner. If instance A is down at 3 a.m., the backup doesn't run, B, C, and D were told false. For a fleet where any instance can take over, read on.

The guarantee

With coordination in place, node-cron guarantees no concurrent execution across instances, effectively once per fire when the instances' clocks are in sync.

It is not a hard exactly-once: under a crash-and-retry, or large clock skew between instances, a fire could still run more than once. Treat distributed tasks as idempotent (safe to run twice) and you're covered for the rare edge. This is a coordination primitive, not a transactional queue, if you need durable, exactly-once job semantics, reach for a queue like BullMQ.

High availability: a custom run coordinator

The env-var default trades availability for simplicity. To let any instance run a fire, only never two at once, you provide a run coordinator backed by something the whole fleet shares (typically Redis). Now there's no special instance: every replica races for each fire, exactly one wins, and if the winner is down another takes over.

A coordinator is just an object that answers the question:

ts
interface RunCoordinator {
  // true  -> this instance runs the fire identified by `key`
  // false -> skip it (another instance handles it)
  // throw -> fail closed (skip), e.g. the backend is unreachable
  shouldRun(key: string, ttlMs: number): boolean | Promise<boolean>;

  // called after the run completes (success or failure); e.g. release a lock
  onComplete?(key: string): void | Promise<void>;
}

Register one globally with setRunCoordinator and it's used for every distributed task instead of the env-var default. Under the hood a lease-based coordinator turns shouldRun into an atomic "claim this key" and onComplete into a safe release, so for the fire keyed nightly-backup:2026-06-17T03:00:00.000Z, the first instance to claim it wins and the rest get false.

The Redis coordinator

The official Redis implementation ships as a separate package, @node-cron/redis-coordinator, so the core stays dependency-free.

You install it alongside node-cron and the Redis client you already use (it supports both ioredis and node-redis v4, and auto-detects which one you passed):

bash
npm install @node-cron/redis-coordinator node-cron redis
bash
npm install @node-cron/redis-coordinator node-cron ioredis

It needs node-cron >= 4.4.1 (the peer dependency) and has zero runtime dependencies of its own: you pass in a client you already created and connected, and the coordinator just uses it (your TLS, Sentinel, Cluster, auth, and retry config stay yours).

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

const redis = createClient(); // your client, your connection
await redis.connect();

setRunCoordinator(new RedisLockCoordinator(redis));

// Deploy on N instances: only one runs each 3am fire, and it survives the loss of any node.
cron.schedule('0 3 * * *', runNightlyBackup, {
  name: 'nightly-backup',
  distributed: true,
  distributedLease: 5 * 60_000, // the backup can take up to ~5 minutes
});

With ioredis it's the same, just hand in the client you have:

js
import Redis from 'ioredis';
setRunCoordinator(new RedisLockCoordinator(new Redis()));

Options: keyPrefix (default node-cron:lock:), clientType ('auto' | 'ioredis' | 'node-redis'), and a logger. It also exposes a healthCheck() that compares the local clock to the Redis server clock, see clock skew below.

Other backends

@node-cron/redis-coordinator is just one implementation of the RunCoordinator interface above. Any object with shouldRun/onComplete works, so you can back coordination with Postgres, etcd, or anything your fleet shares.

Per-task coordinator

A coordinator can also be set on a single task, overriding the global one:

js
cron.schedule('*/5 * * * *', syncInventory, {
  name: 'sync-inventory',
  distributed: true,
  runCoordinator: myCoordinator, // wins over setRunCoordinator() and the env default
});

Resolution order: per-task runCoordinator → global setRunCoordinator → the env-var default.

Knowing when an instance skips

When an instance is not the one chosen to run a fire, it emits execution:skipped instead of running. The context carries a reason:

js
task.on('execution:skipped', (ctx) => {
  if (ctx.reason === 'coordinator-error') {
    // the coordinator failed (e.g. Redis down) and we failed closed.
    // this is the one to alert on: the fire may not have run anywhere.
    alert('cron coordination is failing', ctx);
  }
  // ctx.reason === 'not-elected' is the healthy case: another instance ran it.
});
reasonMeaning
'not-elected'Healthy. Another instance was chosen for this fire.
'coordinator-error'The coordinator threw (e.g. the backend was unreachable). node-cron failed closed and skipped, so the fire may not have run on any instance. Alert on this.

The instance that does run emits the normal execution:startedexecution:finished sequence, so "did this instance run it?" is just "did I get execution:started?", no extra event needed.

distributedLease

Lease-based coordinators (like a Redis lock) hold the claim for a safety window in case the winner crashes mid-run without releasing it. distributedLease (ms, default 30000) sets that lease, and it must exceed the task's run time, or the lease can expire mid-run and a late-arriving instance (delayed by clock skew, GC, or a blocked event loop) could re-acquire the expired key and start a second copy. The env-var default ignores it.

js
cron.schedule('0 3 * * *', runNightlyBackup, {
  name: 'nightly-backup',
  distributed: true,
  distributedLease: 5 * 60_000, // the backup can take up to ~5 minutes
});

Account for jitter

The lease is acquired at the scheduled time, before any maxRandomDelay jitter is applied (the jitter only runs on the instance that won the lock, never affecting which instance wins). So the key stays claimed for jitter + run time, not just the run time. When you combine distributed with maxRandomDelay, size the lease for both:

distributedLease  >  maxRandomDelay + the task's max run time

Otherwise the lease can expire while the winner is still waiting out its jitter or running, reopening the double-run window above.

Clock skew

Coordination keys are built from the scheduled time (name:fireTimeISO), so every instance must agree on what time it is. If two instances' clocks drift apart, they compute different keys for the "same" fire, claim different locks, and both run, the no-concurrent guarantee quietly degrades. Keep your fleet on NTP.

To catch drift before it bites, @node-cron/redis-coordinator exposes a healthCheck() that compares the local clock to the Redis server clock (a shared reference) and reports the skew:

js
const coordinator = new RedisLockCoordinator(redis);
cron.setRunCoordinator(coordinator);

const { ok, driftMs } = await coordinator.healthCheck(); // default threshold 1000ms
if (!ok) {
  console.warn(`clock skew vs Redis is ${driftMs}ms; distributed coordination may be unreliable`);
}

Run it at startup (or on a health endpoint) to surface a misconfigured clock as an alert rather than a duplicate run.

Background tasks work too

distributed works for background tasks exactly as for inline ones, you set the coordinator in your main process and it applies transparently. Internally the forked daemon can't hold the coordinator (it lives across a process boundary), so it asks the parent over IPC, and the parent runs the real coordinator. The shared backend still arbitrates across the fleet; you don't configure anything extra.

js
// in your main process
setRunCoordinator(new RedisLockCoordinator(redis));

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

A note on maxExecutions

maxExecutions is counted per instance. With a per-fire coordinator (the HA case), each instance only counts the fires it won, so the fleet total can exceed your limit. With the single-runner env-var default it behaves as expected, only the designated instance runs and counts.

How it works internally

On each fire of a distributed task, node-cron builds a key from the task's name and the exact scheduled time (name:fireTimeISO), so every instance computes the same key for the same fire. It calls shouldRun(key, ttl); on true it runs the task and then calls onComplete(key); on false (or a thrown error) it emits execution:skipped and moves on. The coordinator is where cross-instance agreement happens, node-cron itself stays a scheduler.

Next steps

Released in 2016 under the ISC License.