Scheduling and Cron Tasks
- nestjs
- nodejs
- backend
- cron
- scheduling
- redis
- distributed-systems
- typescript
Last time, we covered Background Jobs with BullMQ: the queue-backed way to move slow or unreliable work off the request thread. Queues are the right tool when something needs to happen once, triggered by an event: a user signs up, a payment succeeds, a file finishes uploading. This article covers the other kind of background work, the kind that runs on a schedule regardless of whether anything happened: nightly reports, subscription renewal checks, stale session cleanup, cache warmups.
This is part of the NestJS Production Guide series, and scheduling sits right next to queues in the production-hardening phase, because the two get confused constantly. A lot of teams reach for a cron job when they actually need a queue, or build an elaborate queue-based workaround for something a five-line cron decorator would solve.
I will cover the built-in @nestjs/schedule module, the difference between declarative and dynamic scheduling, and the pitfall that catches almost everyone past one instance: the same cron job firing multiple times because nobody told the scheduler it isn't running alone.
Cron jobs versus queued jobs: picking the right tool
The dividing line is simple once you name it: does the work start because of an event, or because of the clock? BullMQ jobs are triggered by application code, usually in response to something a user or another service did. Cron jobs are triggered by time alone, with no event to hang on.
Forcing one tool to do the other's job creates real problems. Running "clean up expired sessions" as a queued job means something still has to enqueue it on a schedule, which is scheduling with an unnecessary queue bolted in the middle. Cramming "process this uploaded file" into a cron job that polls a table every minute works, technically. But it throws away the retry semantics, concurrency control, and job-level observability a proper queue gives you for free.
A useful rule: if you can describe the trigger as "every day at 2am," it's a cron job. If you can describe it as "when X happens," it's a queue job, even if X was triggered by a cron-scheduled poll upstream. Some systems need both: a cron job that scans for work and enqueues it onto BullMQ for the actual processing.
Declarative scheduling with @nestjs/schedule
NestJS ships an official scheduling module built on top of the cron package, and for most scheduled tasks it's all you need. No Redis, no separate infrastructure, just decorators on a provider method.
npm install @nestjs/schedule
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { BillingModule } from './modules/billing/billing.module';
@Module({
imports: [ScheduleModule.forRoot(), BillingModule],
})
export class AppModule {}
// src/modules/billing/subscription-renewal.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SubscriptionsService } from './subscriptions.service';
@Injectable()
export class SubscriptionRenewalService {
private readonly logger = new Logger(SubscriptionRenewalService.name);
constructor(private readonly subscriptions: SubscriptionsService) {}
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'subscription-renewal' })
async handleRenewals(): Promise<void> {
this.logger.log('Starting subscription renewal sweep');
const processed = await this.subscriptions.renewDueSubscriptions();
this.logger.log(`Renewal sweep complete, processed ${processed} subscriptions`);
}
}
ScheduleModule.forRoot() registered once in the root module wires up the whole mechanism; every @Cron decorator anywhere in the app registers itself against it. The name option matters more than it looks. Without it, you can't reference the job later from SchedulerRegistry, and debugging which job is which in logs gets harder past two or three of them.
CronExpression gives you named constants for common patterns (EVERY_DAY_AT_2AM, EVERY_WEEKDAY, EVERY_HOUR), worth using over a hand-written cron string whenever the pattern exists, since it reads clearly at a glance instead of requiring a mental cron-syntax parse. For anything more specific, a raw string works the same way the underlying cron package expects: '0 */15 * * * *' for every fifteen minutes, '0 0 9 * * MON-FRI' for weekday mornings.
Two smaller decorators cover simpler timing needs without cron syntax. @Interval(name, milliseconds) runs a method repeatedly on a fixed delay, and @Timeout(name, milliseconds) runs a method once after the app starts. Both suit things like a health self-check every thirty seconds or a one-time cache warmup on boot, where cron's calendar semantics would be overkill.
Time zones are not optional
@nestjs/schedule runs on server time by default, usually UTC in a containerized deployment, and that default quietly breaks anything meant to run at a specific local time. "Every day at 2am" means something different depending on whether that's UTC, the ops team's local time, or your customers' time zone.
@Cron(CronExpression.EVERY_DAY_AT_2AM, {
name: 'subscription-renewal',
timeZone: 'America/New_York',
})
async handleRenewals(): Promise<void> {
// ...
}
Set timeZone explicitly on anything where the actual wall-clock time matters, rather than assuming the deployment environment's clock matches your intent. It's a small line to add and an easy one to forget. The failure mode is quiet too: the job runs fine, just at the wrong hour, until someone asks why the renewal email went out at 3am local time.
Dynamic scheduling with SchedulerRegistry
Decorators cover a fixed schedule known at compile time. Some problems need a schedule that changes at runtime: a customer sets their own report delivery time, or a feature adds and removes scheduled jobs based on data in the database. SchedulerRegistry lets you manage cron jobs imperatively for that.
// src/modules/reports/scheduled-report.service.ts
import { Injectable } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { ReportsService } from './reports.service';
@Injectable()
export class ScheduledReportService {
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly reports: ReportsService,
) {}
scheduleForTenant(tenantId: string, cronExpression: string): void {
const jobName = `tenant-report-${tenantId}`;
if (this.schedulerRegistry.doesExist('cron', jobName)) {
this.schedulerRegistry.deleteCronJob(jobName);
}
const job = new CronJob(cronExpression, () => {
void this.reports.generateForTenant(tenantId);
});
this.schedulerRegistry.addCronJob(jobName, job);
job.start();
}
cancelForTenant(tenantId: string): void {
const jobName = `tenant-report-${tenantId}`;
if (this.schedulerRegistry.doesExist('cron', jobName)) {
this.schedulerRegistry.deleteCronJob(jobName);
}
}
}
This pattern earns its keep once a schedule is user-configurable or tied to rows in a database rather than fixed at deploy time. It costs you something in return: these jobs live only in the running process's memory, so a restart wipes them, and you need your own logic to reload every dynamic job from the database on startup. Reach for this only when a customer-facing "set your own schedule" feature actually exists; a fixed set of decorator-based cron jobs is simpler to reason about and covers most real needs.
The multi-instance problem
@nestjs/schedule has no concept of other instances. Each running copy of your app registers its own cron job independently and fires it on its own clock. Run two instances behind a load balancer, and your "nightly renewal sweep" runs twice, in parallel, each processing the same rows. For an idempotent read-only report, that's wasted work. For a job that charges a card or sends an email, that's a customer-facing incident. Take it seriously before it happens, not after a duplicate billing run gets discovered in production.
The fix is a distributed lock: before a job does its work, it tries to acquire a lock that every instance can see, and only the instance that gets it proceeds.
// src/modules/billing/subscription-renewal.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import Redis from 'ioredis';
import { SubscriptionsService } from './subscriptions.service';
@Injectable()
export class SubscriptionRenewalService {
private readonly logger = new Logger(SubscriptionRenewalService.name);
private readonly redis = new Redis(process.env.REDIS_URL as string);
constructor(private readonly subscriptions: SubscriptionsService) {}
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'subscription-renewal' })
async handleRenewals(): Promise<void> {
const lockKey = 'lock:subscription-renewal';
const acquired = await this.redis.set(lockKey, '1', 'EX', 300, 'NX');
if (!acquired) {
this.logger.log('Renewal sweep already running on another instance, skipping');
return;
}
try {
const processed = await this.subscriptions.renewDueSubscriptions();
this.logger.log(`Renewal sweep complete, processed ${processed} subscriptions`);
} finally {
await this.redis.del(lockKey);
}
}
}
SET key value EX seconds NX is the same primitive you'd use for a simple distributed lock anywhere else: it only succeeds if the key doesn't already exist, and the expiry means a crashed instance doesn't hold the lock forever. Every instance's cron still fires at the same moment. Only the one that wins the race does the work; the rest see the lock is held and return immediately.
This isn't a full solution for every case. If the job can outlast its own lock's TTL, you need a renewal mechanism or a proper distributed lock library (Redlock, for instance) instead of a bare SET NX. For a job that finishes in seconds to a few minutes, a TTL comfortably longer than the expected run time is enough. That's the right amount of complexity for the problem: don't reach for a full lock library until a single SET NX stops being sufficient.
Production pitfalls
Assuming a single instance forever. The duplication problem above doesn't show up locally or in single-container staging, which is exactly why it surprises teams the first time they scale horizontally.
Long-running jobs blocking the next tick. A cron job that takes longer than its own interval means the next scheduled run either overlaps or queues up behind it. Keep the job lightweight, or have it enqueue the actual work onto BullMQ and return quickly, letting the queue's concurrency controls handle the heavy lifting.
Silent failures. An unhandled exception inside a @Cron method doesn't crash the process; it just logs and moves on, so a broken renewal job can fail quietly for days unless something is actively monitoring for it. Wrap the job body in a try/catch that reports to your error tracker, not just the console.
No missed-run recovery. If the instance is down at 2am when the job would have fired, @nestjs/schedule doesn't queue up a catch-up run; it skips it entirely. Where a missed run has real consequences, build explicit reconciliation logic that finds anything that should have been processed and wasn't, rather than trusting the scheduler to notice a gap.
Key takeaways
- Cron jobs are for work triggered by the clock; queues are for work triggered by an event. Don't build one out of the other.
- `@nestjs/schedule` covers the common case with `@Cron`, `@Interval`, and `@Timeout` decorators, registered once via `ScheduleModule.forRoot()`.
- Set `timeZone` explicitly on time-sensitive jobs; server time defaults quietly produce the wrong wall-clock behavior.
- `SchedulerRegistry` supports dynamic, runtime-configurable schedules, at the cost of needing your own logic to restore jobs after a restart.
- More than one instance means every cron job fires everywhere unless you add a distributed lock; a Redis `SET NX` with a sensible TTL is usually enough.
- Scheduled jobs fail silently and skip missed runs by default. Add explicit error reporting and, where it matters, reconciliation logic for gaps.
Frequently asked questions
What's the difference between @nestjs/schedule and BullMQ's repeatable jobs?
Both can run something on a schedule, but they solve different problems. `@nestjs/schedule` is a lightweight, in-process scheduler with no extra infrastructure. BullMQ's repeatable jobs run through the same Redis-backed queue as everything else, giving you retries, concurrency limits, and job-level observability, at the cost of requiring the queue infrastructure to already be in place.
Do I need Redis just to use @nestjs/schedule?
No. The decorator-based scheduling runs entirely in your Node process with no external dependency. You only need Redis once you're running more than one instance and need a distributed lock to prevent duplicate execution.
Why did my scheduled job run twice after I deployed a second instance?
Because `@nestjs/schedule` has no awareness of other instances; each one registers and fires its own copy of the job independently. Add a distributed lock, such as a Redis `SET NX` with an expiry, so only one instance's execution proceeds per scheduled run.
How do I test a cron job without waiting for its actual schedule?
Extract the scheduled method's logic into a plain injectable method and unit test that directly, calling it manually. For integration tests, `SchedulerRegistry` exposes the underlying `CronJob` instances, and you can trigger `.fireOnTick()` on them directly.
What happens if the app is down when a cron job was supposed to run?
Nothing automatically; `@nestjs/schedule` doesn't queue up a missed execution or catch up later. Where a missed run has real consequences, build explicit reconciliation logic that checks for what should have happened and didn't, rather than trusting the scheduler to notice a gap.
Should I use cron syntax or the named CronExpression constants?
Use the named constants (`EVERY_DAY_AT_2AM`, `EVERY_WEEKDAY`) whenever your schedule matches one, since they're self-documenting at a glance. Fall back to a raw cron string only for schedules the constants don't cover, and add a comment explaining the intent, since cron syntax isn't obvious months later.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.