This article is published in English.
Why NestJS @Cron Jobs Run Once per Replica and How to Fix It
Learn why in-process @Cron schedules multiply when a NestJS service scales out, and how an external trigger, idempotent writes and a guarded endpoint fix it.
A nightly job that should create exactly one record per entity starts producing duplicates: identical rows for the same entity and start date, written seconds apart, neither of them corrupted. Nothing in the job's code changed. What changed is that the service now runs on several replicas, and the scheduler lives inside each one. This article explains why NestJS's @Cron decorator behaves this way, compares three ways to fix it, and walks through the chosen design, including the security and timezone details that come with it.
How the duplicates happen
Consider a job that advances every active entity into its next bounded time window, creating one new record per entity each day. The idiomatic NestJS implementation uses the @nestjs/schedule decorator:
@Injectable()
export class WindowGenerationService {
@Cron('0 8 * * *') // every day at 08:00
async generateNextWindows() {
const entities = await this.repo.findActiveEndingSoon();
for (const entity of entities) {
await this.repo.createNextWindow(entity);
}
}
}
This is exactly what the documentation suggests, and it works flawlessly while the service runs as a single instance.
The trouble begins after horizontal scaling. With three instances, there are three processes that each boot the module, and @Cron registers its timer in every one of them. Nothing coordinates them: at 08:00 all three fire simultaneously. Because the job performs no idempotency check and takes no lock, each run looks for an existing window, finds none, and writes its own copy.
Two costs follow. The obvious one is duplicate data. The less obvious one is waste: every replica does the same work at the same moment, and then more effort goes into cleaning up the result. An in-process scheduler in a scaled service is not only a correctness bug; it spends compute in proportion to replica count by design. Nothing in the code hints at this, which is why it tends to surface in staging data rather than in review.
Three ways to fix it
Option 1: a distributed lock
Keep @Cron, but make the instances compete for a lock, such as a database advisory lock or a key in a cache, and let only the winner run. This works, but it swaps a visible failure for an invisible one. Duplicate rows can at least be found and deleted. A skipped run cannot: if the lock backend is unavailable at 08:00, or an instance crashes while holding the lock before its TTL expires, the job simply does not happen, and nobody notices until a missing window causes trouble days later. It also adds a new dependency, and a new silent failure mode, to compensate for a timer placed in the wrong layer.
Option 2: idempotency alone
Make the job safe to run more than once, so extra runs do nothing. This is cheap and correct, but three containers still wake up every night to do redundant work.
Option 3: move scheduling out of the application
The application should not decide when work runs. Let an external scheduler own the clock and send one HTTP request to an ordinary endpoint; the application only decides what happens when it receives that request. One trigger produces one run, and adding replicas no longer multiplies anything. Common homes for that trigger are a Kubernetes CronJob, a cloud provider's scheduler service or a CI pipeline schedule.
The chosen design combines option 3 with the idempotency from option 2 as a safety net.
The new design
The @Cron decorator is removed, and the job logic sits behind an endpoint:
@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
this.assertValidSecret(body.secret);
return this.jobs.run(body.jobKey);
}
The external scheduler calls this once at the scheduled time. The load balancer routes the request to one instance, which runs the job; the other replicas are never involved. Passing a jobKey lets a single endpoint dispatch several jobs.
A practical refinement: a job that runs for minutes may outlast the scheduler's HTTP timeout. For long jobs, consider acknowledging the request quickly and running the work in the background, while still guarding against overlap.
Keeping idempotency as a seatbelt
The idempotency check remains, because "fires exactly once" is a promise that infrastructure usually keeps but occasionally breaks: a scheduler retries after a timeout, someone triggers the job manually, or an instance restarts partway through.
async createNextWindow(entity: Entity) {
const existing = await this.repo.findByEntityIdAndStartDate(
entity.id,
entity.nextStartDate,
);
if (existing) return; // already done, no-op
await this.repo.create(/* ... */);
}
Before creating a window, the method looks for one with the same entity id and start date and returns early if it exists. Be aware that check-then-insert is itself racy if two runs overlap exactly. The dependable backstop is a unique constraint in the database on the entity id and start date, so a concurrent duplicate fails at insert time instead of being written. For a deeper look at designing retry-safe writes, see our guide to idempotency keys in Node.js POST endpoints.
What the change costs
A job endpoint is a public endpoint
Turning a private nightly job into an HTTP route creates a button that anyone who discovers it can press, repeatedly. The route therefore requires a shared secret, and how that secret is compared matters:
private assertValidSecret(provided: string) {
const expected = this.config.cronSecret;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new UnauthorizedException();
}
}
Comparing with provided === expected can leak information through timing: string comparison may stop at the first mismatching character, so the time to fail hints at how much of a guess was correct, allowing an attacker to recover the secret piece by piece. Node's timingSafeEqual from the crypto module compares in constant time. It requires buffers of equal length, which is why the length is checked first; hashing both values before comparing avoids revealing even the length. Reviewers rarely flag this, and relying on the endpoint staying undiscovered is not a strategy.
Two further hardening steps are worth considering. Sending the secret in a request header rather than the body keeps it out of body-logging middleware. And validating that secret is a non-empty string in RunJobDto prevents Buffer.from from throwing on missing input.
Choosing the hour is a product decision
The second cost is easy to underestimate: picking the time. The job must run after the start of the day for every user, and users span several US time zones, so an hour that is mid-morning on the east coast is still before dawn on the west coast. The schedule is therefore pinned to a fixed UTC hour chosen against the westernmost time zone the business operates in, since that is the time that is safe everywhere. The crontab speaks UTC while the requirement speaks local time, and the translation between the two is where the real decision is made. Write that reasoning down next to the schedule, because it is not obvious from the cron expression alone.
Key takeaways
@Cronruns in every process that loads the module, so its runs multiply with your replica count without any warning in the code.- Distributed locks fix duplicates but introduce silent missed runs and an extra dependency.
- Scheduling is a "when" concern that belongs to external infrastructure; the application should own only "what happens" when triggered.
- Keep idempotency anyway, ideally enforced by a unique database constraint, because single delivery is someone else's promise.
- A job endpoint needs real protection: a secret compared with
timingSafeEqual, input validation and sensible logging. - Define schedules in UTC deliberately, based on the time zone that constrains the requirement most.