DEV Community

Cover image for πŸš€ Handling Cron Jobs in NestJS with Multiple Instances using Bull
Juan Castillo
Juan Castillo

Posted on • Edited on

πŸš€ Handling Cron Jobs in NestJS with Multiple Instances using Bull

A Complete Guide with Workers, Rate Limiting, Retries & Dead-Letter Queues (DLQs) πŸš€πŸ”₯

When your NestJS application grows, running cron jobs becomes… tricky.
Running multiple instances means your cron jobs will run multiple times, which is usually very bad πŸ˜….

To avoid duplicate jobs and chaos, we’ll build a robust distributed cron system using:

  • NestJS
  • BullMQ (modern queue system)
  • Redis
  • Worker processes
  • Retry logic
  • Dead-letter queues (DLQs)
  • Rate-limiting

And all wrapped in a multi-instance setup using Docker Compose.
Let’s go. πŸ› οΈβœ¨


🧱 Architecture Overview

We will build two separate NestJS applications:

                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚  Scheduler Service β”‚  ← runs cron jobs
                        β”‚  (NestJS App #1)   β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚  pushes jobs
                                   β–Ό
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                            β”‚   BullMQ    β”‚
                            β”‚  (Redis)    β”‚
                            β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚  workers pull jobs
                                   β–Ό
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚   Worker Service   β”‚  ← processes jobs
                        β”‚  (NestJS App #2)   β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode
  • Only the Scheduler runs cron jobs
  • Only the Worker processes jobs
  • Both communicate through Redis
  • All instances stay synchronized
  • No duplicated tasks πŸŽ‰

🧰 Step 1 β€” Install Redis

The easiest method is Docker:

docker run -d --name redis -p 6379:6379 redis:7
Enter fullscreen mode Exit fullscreen mode

πŸ“¦ Step 2 β€” Install Dependencies

Install BullMQ & NestJS integration:

npm i bullmq @nestjs/bullmq
npm i -D @types/bull
Enter fullscreen mode Exit fullscreen mode

Install scheduling support:

npm i @nestjs/schedule
Enter fullscreen mode Exit fullscreen mode

(Optional) install Bull Dashboard:

npm i @bull-board/api @bull-board/express
Enter fullscreen mode Exit fullscreen mode

πŸ”Œ Step 3 β€” Create a Shared Redis Config

Create src/bull-config.ts:

export const redisConfig = {
  connection: {
    host: process.env.REDIS_HOST ?? 'localhost',
    port: Number(process.env.REDIS_PORT ?? 6379),
  },
};
Enter fullscreen mode Exit fullscreen mode

You’ll reuse this in both apps.


πŸ•’ Step 4 β€” Build the Scheduler App

(The app that runs cron jobs and pushes them into queues)

Enable ScheduleModule:

// scheduler/app.module.ts
@Module({
  imports: [
    ScheduleModule.forRoot(),
    BullModule.forRoot(redisConfig),
    BullModule.registerQueue({
      name: 'email_queue',
    }),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Create a Cron Job

// scheduler/email-cron.service.ts
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';

@Injectable()
export class EmailCronService {
  constructor(@InjectQueue('email_queue') private emailQueue: Queue) {}

  @Cron(CronExpression.EVERY_10_SECONDS)
  async handleCron() {
    console.log('⏰ Adding job to email queue...');

    await this.emailQueue.add(
      'send-welcome-email',
      { userId: Math.floor(Math.random() * 1000) },
      {
        attempts: 3,
        backoff: { type: 'exponential', delay: 3000 },
        removeOnComplete: true,
        removeOnFail: false,
        rateLimiter: {
          max: 5,
          duration: 1000,
        },
      }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

What we added:

  • πŸ”„ Retries (3 attempts)
  • ⏳ Exponential backoff
  • 🚦 Rate-limiting (5 jobs per second)
  • 🧹 Cleanup completed jobs

πŸ› οΈ Step 5 β€” Build the Worker App

(This app consumes & executes queue jobs)

// worker/app.module.ts
@Module({
  imports: [
    BullModule.forRoot(redisConfig),
    BullModule.registerQueue({
      name: 'email_queue',
    }),
  ],
  providers: [EmailProcessor],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Create the Job Processor

// worker/email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';

@Processor('email_queue')
export class EmailProcessor extends WorkerHost {
  async process(job: Job<any>): Promise<any> {
    try {
      console.log(`πŸ“¨ Processing email job for User ${job.data.userId}`);

      if (Math.random() < 0.3) {
        throw new Error('Simulated email failure!');
      }

      return { success: true };
    } catch (err) {
      console.error('❌ Job failed:', err);
      throw err;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’€ Step 6 β€” Dead-Letter Queue (DLQ)

Create a secondary queue called email_queue:dlq.

Modify worker:

@Processor('email_queue')
export class EmailProcessor extends WorkerHost {
  async onFailed(job: Job, error: any) {
    const dlq = new Queue('email_dlq', redisConfig);

    await dlq.add('dlq-job', {
      failedJob: job.id,
      data: job.data,
      error: error.message,
    });

    console.log(`πŸ’€ Moved job ${job.id} to DLQ`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now any job that fails after max retries goes into the dead-letter queue,
ready for manual review or reprocessing.


🧱 Step 7 β€” Dockerize Everything

Here is the full working docker-compose.yml:

version: "3.9"

services:
  redis:
    image: redis:7
    ports:
      - "6379:6379"

  scheduler:
    build: ./scheduler
    depends_on:
      - redis
    environment:
      REDIS_HOST: redis
    deploy:
      replicas: 1

  worker:
    build: ./worker
    depends_on:
      - redis
    environment:
      REDIS_HOST: redis
    deploy:
      replicas: 3
Enter fullscreen mode Exit fullscreen mode

What this achieves:

  • Scheduler has 1 replica β†’ cron runs once
  • Worker can have as many replicas as needed β†’ job processing scales horizontally

πŸ“Š Optional: Add BullMQ Dashboard

Inside the worker app:

import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';

@Module({})
export class DashboardModule implements OnModuleInit {
  onModuleInit() {
    const serverAdapter = new ExpressAdapter();
    const emailQueue = new Queue('email_queue', redisConfig);

    createBullBoard({
      queues: [new BullMQAdapter(emailQueue)],
      serverAdapter,
    });

    serverAdapter.setBasePath('/dashboard');
    app.use('/dashboard', serverAdapter.getRouter());
  }
}
Enter fullscreen mode Exit fullscreen mode

Open dashboard:

http://localhost:3000/dashboard
Enter fullscreen mode Exit fullscreen mode

πŸŽ‰ Final Result: A Rock-Solid Cron Job System

You now have:

βœ… A Scheduler app that runs cron jobs
βœ… A Worker app that processes tasks
❌ No duplicated cron executions
🚦 Rate-limiting
πŸ” Retry logic with exponential backoff
πŸ’€ Dead-letter queues
🐳 Horizontal scaling with Docker
πŸŽ›οΈ Optional admin dashboard

All production-ready.
All beautifully decoupled.
All powered by Redis & BullMQ.

Top comments (2)

Collapse
 
ivanva437367ea87d profile image
ivanva

This definitely does not solve the problems in the conclusion section: multiple instance will introduce one job in the queue each, geneating N jobs executions. In particular:

  • cron jobs are executed N times (N=instance numbers) per each interval
  • multiple instances DOES duplicate executions
Collapse
 
juan_castillo profile image
Juan Castillo

Hi ivanva,
Thanks for the comment, and you're absolutely right, so I corrected the article and added a couple of extras.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.