Skip to content
ExponaBLOG
NESTJS · DAY 036 / 100

Scheduled tasks with @nestjs/schedule: a practical guide

Cron jobs inside your NestJS app.

Expona Admin7 min read

Scheduled tasks with @nestjs/schedule

Every production application eventually needs something to run on a schedule—cleaning up expired data, sending digest emails, syncing external APIs, or generating reports. In NestJS, the @nestjs/schedule package handles this elegantly without requiring a separate job queue or external orchestration tool. Today we're covering how to add declarative cron jobs, intervals, and dynamic scheduling directly into your application.

Why you need scheduled tasks

I've built several platforms where cron jobs were critical to system health. In one e-commerce system, we cleaned up abandoned cart sessions every night. In a travel platform, we synced hotel availability every 30 minutes. In a trading app, we calculated daily settlements. Without proper scheduling, you either hire a DevOps team to manage separate cron services or you lose data integrity.

The traditional approach was to spin up a separate service—maybe a scheduled Lambda, a Jenkins job, or a standalone Node script running under Supervisor. But @nestjs/schedule lets you keep everything in one place: your NestJS application itself becomes the scheduler.

Setting up @nestjs/schedule

First, install the package:

typescript
npm install @nestjs/schedule

Then import ScheduleModule into your root module:

typescript
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SessionsService } from './sessions/sessions.service';

@Module({
  imports: [ScheduleModule.forRoot()],
  providers: [SessionsService],
})
export class AppModule {}

That's it. Now any service in your application can use scheduling decorators.

Declarative cron—the most common pattern

The cleanest way to schedule work is the @Cron decorator. You decorate a method, give it a cron expression, and NestJS handles the rest. Here's a real example from one of my projects:

typescript
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SessionsRepository } from './sessions.repository';
import { Logger } from '@nestjs/common';

@Injectable()
export class SessionsService {
  private readonly logger = new Logger(SessionsService.name);

  constructor(private readonly sessionsRepo: SessionsRepository) {}

  @Cron(CronExpression.EVERY_DAY_AT_2AM)
  async cleanupExpiredSessions() {
    this.logger.log('Starting expired session cleanup...');
    const deletedCount = await this.sessionsRepo.deleteExpired();
    this.logger.log(`Deleted ${deletedCount} expired sessions`);
  }
}

When your application starts, NestJS automatically registers this cron job. At 2 AM every day, the cleanupExpiredSessions method runs automatically. No manual scheduling, no external services, no config files.

NestJS provides convenient expressions like EVERY_DAY_AT_2AM, but you can also write raw cron syntax:

typescript
// Run every 15 minutes
@Cron('0 */15 * * * *')
async syncExternalApi() {
  // ...
}

// Run every Monday at 9 AM
@Cron('0 9 * * 1')
async weeklyReport() {
  // ...
}

The cron expression format is the standard Linux/Unix format: second minute hour day month dayOfWeek. If you're not comfortable writing cron expressions, use the predefined constants or an online cron generator.

Intervals and timeouts for simpler recurring work

Not all scheduled work needs to be time-based. Sometimes you just want something to run every N milliseconds, or once after a delay. That's where @Interval and @Timeout come in.

I used @Interval in a ride-hailing app to check for stale driver locations every 5 seconds:

typescript
import { Injectable } from '@nestjs/common';
import { Interval, Timeout } from '@nestjs/schedule';

@Injectable()
export class DriverLocationService {
  @Interval(5000) // Run every 5 seconds (milliseconds)
  async cleanupStaleLocations() {
    const removed = await this.db.drivers
      .deleteMany({
        lastSeenAt: { $lt: new Date(Date.now() - 30000) },
      });
    console.log(`Removed ${removed.deletedCount} stale locations`);
  }
}

And @Timeout runs once after the application boots, with a delay:

typescript
@Injectable()
export class HealthCheckService {
  @Timeout(5000) // Run once, 5 seconds after boot
  async warmupDatabaseConnection() {
    console.log('Warming up DB connection pool...');
    await this.db.query('SELECT 1');
  }
}

@Interval is useful for lightweight health checks, cache refreshes, or any recurring task that doesn't need precise timing. @Timeout is perfect for initialization tasks that should happen shortly after your app is ready.

Dynamic jobs with SchedulerRegistry

Sometimes you need to add, remove, or modify jobs at runtime. A SaaS platform might let users customize their notification schedule. A trading system might add new market sync jobs based on user subscription. That's where SchedulerRegistry shines.

typescript
import { Injectable, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';

@Injectable()
export class NotificationService {
  private readonly logger = new Logger(NotificationService.name);

  constructor(private readonly scheduler: SchedulerRegistry) {}

  // Add a dynamic cron job
  addDailyNotification(userId: string, hour: number, minute: number) {
    const jobName = `daily-notification-${userId}`;
    
    // Check if job already exists
    try {
      this.scheduler.getCronJob(jobName);
      this.logger.warn(`Job ${jobName} already exists`);
      return;
    } catch (error) {
      // Job doesn't exist, continue
    }

    const cronExpression = `0 ${minute} ${hour} * * *`;
    const job = new CronJob(cronExpression, () => {
      this.logger.log(`Sending notification to user ${userId}`);
      this.sendNotification(userId);
    });

    this.scheduler.addCronJob(jobName, job);
    job.start();
    this.logger.log(`Added cron job: ${jobName}`);
  }

  // Remove a dynamic job
  removeDailyNotification(userId: string) {
    const jobName = `daily-notification-${userId}`;
    try {
      const job = this.scheduler.getCronJob(jobName);
      job.stop();
      this.scheduler.deleteCronJob(jobName);
      this.logger.log(`Removed cron job: ${jobName}`);
    } catch (error) {
      this.logger.warn(`Job ${jobName} not found`);
    }
  }

  // List all active cron jobs
  listAllJobs() {
    const jobs = this.scheduler.getCronJobs();
    jobs.forEach((job, name) => {
      console.log(`${name}: ${job.nextDate()}`);
    });
  }

  private sendNotification(userId: string) {
    // Actual notification logic
  }
}

You can also add dynamic intervals:

typescript
const jobName = `sync-market-${marketId}`;
const callback = () => this.syncMarketData(marketId);
this.scheduler.addInterval(jobName, 30000, callback); // Every 30 seconds

// Later, remove it
this.scheduler.deleteInterval(jobName);

Use SchedulerRegistry when your scheduling logic is data-driven—when the schedule depends on user preferences, configuration, or runtime decisions.

Common mistakes

  • Running jobs on every replica. If you deploy three instances of your NestJS app, every scheduled task runs on all three simultaneously. You'll send three copies of the same email or process the same data three times. We'll solve this next post with distributed locking.
  • Forgetting to handle errors. If your cron job throws, it might silently fail or crash your application. Always wrap async work in try-catch and log properly.
  • Long-running tasks blocking other work. A cron job that takes 20 minutes will hold a thread and may delay other scheduled tasks. For heavy work, consider a separate queue (Bull, RabbitMQ) instead.
  • Cron expressions that don't do what you think. Testing cron syntax is easy to get wrong. Use an online validator or hardcode known expressions like CronExpression.EVERY_DAY_AT_2AM.
  • Not logging job execution. Six months later, you won't remember which jobs exist or when they ran. Always log start and end times, and especially log errors.

Key takeaways

  • @Cron is your go-to for time-based scheduling; use predefined expressions when possible to avoid syntax errors.
  • @Interval and @Timeout handle millisecond-based scheduling for simpler recurring tasks.
  • SchedulerRegistry lets you add, remove, and manage jobs at runtime for data-driven scheduling scenarios.
  • Every replica runs every cron job by default—you'll need distributed locking or a single scheduler instance for production multi-instance deployments.
  • Always log job execution and handle errors gracefully; silent failures in background tasks are nightmare fuel.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles