Skip to content

NestJS scheduled automation

Optimizing Backend Systems: How I Employed a CRON Job in a Nest.js Microservice

How a scheduled NestJS microservice consolidated repeated fleet-location requests into a server-side job designed for a much larger client load.

9 min readBy

Background: a fleet-location backend

The backend served a truck-management application for businesses. Each business managed multiple drivers and needed to track their truck locations, so driver clients sent location updates at regular intervals while business and administrator clients consumed that data.

The initial backend was straightforward because there was little user activity. The design problem emerged when the same API had to support a projected load of approximately 10,000 daily active users and a rapidly growing location history.

The location endpoint became the bottleneck

Horizontal scaling and a load balancer could distribute incoming queries, but they did not remove the work inside the location-update endpoint. Each driver could retain a five-day window containing 1,440 points: 12 points per hour, 288 per day, with points spaced five minutes apart.

Keeping the array ordered meant either shift followed by push, with O(n) + O(1) work, or pop followed by unshift, with O(1) + O(n) work. On top of that array maintenance, each update accumulated time calculations, coordinate checks, data validation, and database persistence.

Driver clients were not a reliable clock. A device could send five requests inside one five-minute interval or send nothing through a network dead zone. Request volume and array-processing work both grew with the number of active drivers.

At only 1,000 clients making one request per minute, the monolith would receive 1,000 requests per minute and roughly 1.4 million requests per day.

Split verification from database integration

The design separated intake and verification from integration with the main database. An automation service collected location data and became responsible for pushing each truck's pending updates into the monolith at a controlled boundary.

A cron job ran every five minutes and sent one bulk request containing arrays of locations. The measurable design change is request shape, not proven database speed: many client writes became one batch request per scheduler instance every five minutes.

The same microservice also listened for incoming API calls and could return analytical information, while the scheduled boundary controlled when accumulated data reached the monolith.

Create the NestJS microservice

Install the Nest CLI, create the project, and add the official microservices, scheduling, and HTTP packages.

npm i -g @nestjs/cli
nest new project-name
# Or create the project in the current directory:
nest new .
npm i --save @nestjs/microservices
npm install --save @nestjs/schedule
npm install --save-dev @types/cron
npm i --save @nestjs/axios axios

The bootstrap below runs a normal HTTP server on port 8080 and connects a TCP microservice on port 8081. A service that does not need HTTP can omit the HTTP listener.

import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { Transport } from '@nestjs/microservices'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  app.connectMicroservice({
    transport: Transport.TCP,
    options: {
      port: 8081
    }
  })

  await app.startAllMicroservices()
  await app.listen(8080)
}

bootstrap()

AppModule composes the location and scheduler modules so incoming location behavior and scheduled persistence remain separate.

import { Module } from '@nestjs/common'
import { LocationModule } from './location/location.module'
import { SchedulerModule } from './scheduler/scheduler.module'

@Module({
  imports: [SchedulerModule, LocationModule],
  providers: []
})
export class AppModule {}

Keep the cron job in a dedicated module

The location module owns its controller and service. The controller handles incoming GET requests and location_updated events, while the service can perform location operations or create dynamic jobs in response to events.

Scheduling stays in a dedicated module so Nest does not register unnecessary duplicate jobs. The location service must also be one shared provider. Providing LocationService again inside SchedulerModule would create another Nest instance and could leave the scheduler reading a different in-memory buffer from the controller.

import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { HttpModule } from '@nestjs/axios'
import { LocationModule } from '../location/location.module'
import { SchedulerService } from './scheduler.service'

@Module({
  imports: [ScheduleModule.forRoot(), HttpModule, LocationModule],
  providers: [SchedulerService]
})
export class SchedulerModule {}

Schedule and send the bulk update

The scheduler uses CronExpression.EVERY_5_MINUTES. HttpService returns an Observable, so firstValueFrom resolves the PATCH request. The next methods are pseudocode: peek reads without deleting, and acknowledge removes only a batch confirmed by the monolith. The 2023 sample did not implement durable acknowledgement.

import { HttpService } from '@nestjs/axios'
import { Injectable } from '@nestjs/common'
import { Cron, CronExpression } from '@nestjs/schedule'
import { firstValueFrom } from 'rxjs'
import { externalLinks } from 'src/links'
import { LocationService } from 'src/location/location.service'

@Injectable()
export class SchedulerService {
  constructor(
    private readonly locationService: LocationService,
    private readonly httpService: HttpService
  ) {}

  @Cron(CronExpression.EVERY_5_MINUTES)
  async locationBulkUpdateJob() {
    const batch = this.locationService.peekPendingLocations()
    if (batch.length === 0) return

    await firstValueFrom(
      this.httpService.patch(externalLinks.backendProdLink, { locations: batch })
    )

    this.locationService.acknowledgeLocations(batch)
  }
}
  • RxJS provides Observables and operators for the asynchronous response and error path.
  • @nestjs/schedule registers the cron job and supplies predefined expressions such as EVERY_5_MINUTES, equivalent to */5 * * * *.
  • @nestjs/axios exposes Axios through Nest's HttpService for the PATCH request to the monolith.

What the sample does not solve

A cron decorator only schedules work. It does not prevent duplicate runs, preserve buffered data after a restart, or guarantee that one replica owns the schedule.

  • Persist pending locations so a restart cannot erase the interval's data.
  • Attach an idempotency or batch key so a retry cannot write the same locations twice.
  • Acknowledge a batch only after the monolith confirms it; retain it on timeout or failure.
  • Prevent overlapping runs when one PATCH lasts longer than five minutes.
  • Bound batch size and define backpressure when intake is faster than integration.
  • Run the schedule through one elected worker, a distributed lock, or an external scheduler. Every application replica otherwise registers the same cron job.

Result and references

The scheduler changes many device writes into a batch every five minutes. It creates one place to verify, retry, and acknowledge work, but I do not have production measurements for database load, uptime, or delivery rate.

  • The original problem combined growing client traffic with an increasingly expensive location-update endpoint.
  • A separate automation microservice became responsible for sending bulk location updates to the monolith.
  • The cron job runs every five minutes and consolidates pending locations into one request.
  • The implementation uses NestJS microservices, task scheduling, the HTTP module, and RxJS.