In-process cron (node-cron, @nestjs/schedule, OS crontab) runs inside one Node process. That is fine for a single instance, but it does not survive restarts gracefully, deduplicate across replicas, or share infrastructure with your other background jobs.
BullMQ stores queues and schedulers in Redis. Job Schedulers (BullMQ 5.16+) are the recommended way to enqueue recurring work on a cron pattern or fixed interval. The same workers that process one-off jobs also process scheduled ones, with retries, backoff, and concurrency you already get from BullMQ.
This post covers Job Schedulers in plain Node.js, operations and pitfalls, a NestJS setup with @nestjs/bullmq, and a runnable demo with a fast cron heartbeat and a daily cleanup cron.
Prerequisites
- Node.js version 26
- Redis at
redis://localhost:6379(included in the demodocker-compose.yml, or use Postgres and Redis containers with Docker Compose) npm i bullmq
For the NestJS section: npm i @nestjs/bullmq bullmq
BullMQ 2.0+ does not require a separate QueueScheduler instance. Use the Job Scheduler API (upsertJobScheduler), not the deprecated repeat option on queue.add().
Mental model
Piece Role Queue Holds jobs waiting to run Worker Executes jobs Job Scheduler Factory that enqueues jobs on a schedule Scheduled job A job instance produced by a schedulerA scheduler id is stable across deploys. Calling upsertJobScheduler with the same id updates the schedule in place instead of creating duplicates.
Queue and worker
Share one Redis connection config between the queue and the worker:
import { Queue, Worker } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
const queue = new Queue('reports', { connection });
const worker = new Worker(
'reports',
async (job) => {
console.log(`[${job.name}]`, new Date().toISOString(), job.data);
},
{ connection },
);
worker.on('failed', (job, error) => {
console.error(job?.name, error.message);
});
Enter fullscreen mode Exit fullscreen mode
Start the worker before or shortly after registering schedulers. If no worker is running, jobs accumulate in Redis until one picks them up.
Cron schedulers
BullMQ uses a 6-field cron expression (optional seconds). A fast pattern for demos and heartbeats:
await queue.upsertJobScheduler(
'report-heartbeat',
{ pattern: '*/10 * * * * *' },
{
name: 'heartbeat',
data: { source: 'scheduler' },
opts: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 50,
},
},
);
Enter fullscreen mode Exit fullscreen mode
Daily cleanup at 03:15 in a specific timezone:
await queue.upsertJobScheduler(
'daily-cleanup',
{ pattern: '0 15 3 * * *', tz: 'Europe/Berlin' },
{
name: 'cleanup',
data: { scope: 'stale-sessions' },
opts: { attempts: 3 },
},
);
Enter fullscreen mode Exit fullscreen mode
Set tz when the job must fire at a local wall-clock time. For millisecond intervals instead of cron, use every (mutually exclusive with pattern).
Other useful repeat options:
Option Purposelimit
Maximum number of iterations
immediately
Run once now, then follow the schedule
startDate / endDate
Bound the scheduler to a time window
Register schedulers on startup
Keep scheduler registration in a dedicated bootstrap script or onModuleInit hook so deploys upsert the same ids:
// scheduler.js
import { Queue } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
const queue = new Queue('reports', { connection });
await queue.upsertJobScheduler(
'report-heartbeat',
{ pattern: '*/10 * * * * *' },
{ name: 'heartbeat', data: { source: 'scheduler' } },
);
await queue.upsertJobScheduler(
'daily-cleanup',
{ pattern: '0 15 3 * * *', tz: 'Europe/Berlin' },
{ name: 'cleanup', data: { scope: 'stale-sessions' } },
);
const schedulers = await queue.getJobSchedulers();
console.log(
'Active schedulers:',
schedulers.map((item) => ({ key: item.key, pattern: item.pattern })),
);
await queue.close();
Enter fullscreen mode Exit fullscreen mode
To remove a scheduler:
await queue.removeJobScheduler('daily-cleanup');
Enter fullscreen mode Exit fullscreen mode
Shut down cleanly on SIGINT / SIGTERM: await worker.close() and await queue.close().
NestJS with @nestjs/bullmq
NestJS wraps BullMQ queues and workers as providers. Register Redis once, register the queue, inject it into a service that upserts schedulers on startup, and process jobs in a @Processor class.
// app.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ReportsProcessor } from './reports.processor';
import { ReportsSchedulerService } from './reports-scheduler.service';
@Module({
imports: [
BullModule.forRoot({
connection: { host: 'localhost', port: 6379 },
}),
BullModule.registerQueue({ name: 'reports' }),
],
providers: [ReportsProcessor, ReportsSchedulerService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode
// reports-scheduler.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
@Injectable()
export class ReportsSchedulerService implements OnModuleInit {
constructor(@InjectQueue('reports') private readonly reportsQueue: Queue) {}
async onModuleInit() {
await this.reportsQueue.upsertJobScheduler(
'report-heartbeat',
{ pattern: '*/10 * * * * *' },
{ name: 'heartbeat', data: { source: 'nestjs' } },
);
await this.reportsQueue.upsertJobScheduler(
'daily-cleanup',
{ pattern: '0 15 3 * * *', tz: 'Europe/Berlin' },
{ name: 'cleanup', data: { scope: 'stale-sessions' } },
);
}
}
Enter fullscreen mode Exit fullscreen mode
// reports.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
@Processor('reports')
export class ReportsProcessor extends WorkerHost {
async process(job: Job): Promise<void> {
console.log(`[${job.name}]`, new Date().toISOString(), job.data);
}
}
Enter fullscreen mode Exit fullscreen mode
ReportsSchedulerService runs when the Nest app boots, so schedulers are upserted on every deploy. ReportsProcessor is the worker; Nest registers it automatically unless you set manualRegistration on BullModule.forRoot.
@nestjs/schedule (@Cron()) is still a good fit for trivial timers inside one instance. Prefer BullMQ schedulers when you already use Redis queues, run multiple replicas, or need the same retry and observability model as the rest of your background jobs.
Pitfalls
- Worker must be running – schedulers enqueue jobs; something must consume them.
-
Busy queues slip – BullMQ creates the next scheduled job when the previous one starts processing. Under load, ticks can be less frequent than
everyor the cron interval suggests. -
patternvsevery– mutually exclusive; pick one per scheduler. -
Timezone – omit
tzand cron runs in the server default zone; set it explicitly for “9 AM local” jobs. -
Legacy
repeatonadd()– deprecated from BullMQ 5.16; useupsertJobSchedulerfor new code.
When to use what
Approach Good for@nestjs/schedule / node-cron
Single instance, simple in-process timers
BullMQ Job Schedulers
Multi-instance apps, shared Redis, retries with async jobs
External cron + HTTP
Fire-and-forget HTTP triggers without queue semantics
Need help with your project?
Get personalized advice on your architecture, code, or career in a 45-minute 1-on-1 consultation.
답글 남기기