BullMQNestJSRedisTypeScriptBackend

BullMQ avec NestJS : gérez vos jobs asynchrones avec Redis

Mettez en place une file de jobs asynchrones robuste avec BullMQ et NestJS : workers, retries, scheduling et monitoring, le tout en TypeScript.

AMAlexis Mouchon10 min de lecture

À un moment ou un autre, toute API finit par croiser un traitement qui n'a rien à faire dans le cycle requête/réponse : envoyer un email transactionnel, générer un PDF, redimensionner une image, appeler une API tierce lente, recalculer un agrégat. Si vous laissez ces tâches dans le contrôleur, le client attend, vos timeouts explosent et le moindre échec de service externe fait planter la requête. BullMQ, couplé à NestJS et Redis, transforme ce genre de traitement en un job asynchrone fiable, retryable et observable.

Dans cet article, je détaille le setup que j'utilise en production : configuration du module, déclaration des queues, écriture des workers, gestion des retries, scheduling, et monitoring via Bull Board.

Pourquoi BullMQ plutôt qu'une simple promesse async ?

Lancer une tâche en arrière-plan avec void doSomething() paraît tentant : la requête répond immédiatement, le traitement continue. En pratique, c'est une fausse bonne idée. Si le serveur redémarre pendant le traitement, le travail est perdu sans trace. Aucun retry automatique. Aucune visibilité sur ce qui tourne. Et si vous scalez à plusieurs instances, chaque process exécute la tâche en doublon.

BullMQ règle ces problèmes en s'appuyant sur Redis comme broker persistant. Chaque job est sérialisé dans Redis, consommé par un (ou plusieurs) worker, et son état est suivi : waiting, active, completed, failed, delayed. Les retries sont gérés nativement, le job survit à un crash, et plusieurs instances de votre API peuvent partager la même file sans risque de double exécution.

Comparé à une stack à base de RabbitMQ ou Kafka, BullMQ a l'énorme avantage d'être trivial à déployer : une instance Redis suffit, et vous l'avez probablement déjà pour le cache. Pour 90 % des besoins backend que je rencontre (envois d'emails, traitements asynchrones d'upload, webhooks sortants, jobs planifiés), c'est l'outil parfait.

Installation et configuration du module

Le package officiel pour NestJS est @nestjs/bullmq, à ne pas confondre avec @nestjs/bull qui s'appuie sur l'ancienne version Bull (en mode maintenance).

pnpm add @nestjs/bullmq bullmq ioredis

Côté AppModule, on importe BullModule.forRoot une seule fois pour configurer la connexion Redis partagée entre toutes les queues :

import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    BullModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        connection: {
          host: config.get<string>('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get<string>('REDIS_PASSWORD'),
        },
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

Notez que je passe par forRootAsync pour récupérer la config via @nestjs/config. En production, vous voulez impérativement que les credentials Redis viennent d'une variable d'environnement, pas d'un fichier commité.

Déclarer une queue et l'injecter dans un service

Une queue est l'objet logique qui reçoit les jobs. On la déclare dans le module métier qui en a besoin :

import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { MailService } from './mail.service';
import { MailProcessor } from './mail.processor';

@Module({
  imports: [
    BullModule.registerQueue({
      name: 'mail',
    }),
  ],
  providers: [MailService, MailProcessor],
  exports: [MailService],
})
export class MailModule {}

Dans le service, on injecte la queue avec @InjectQueue et on ajoute des jobs via queue.add :

import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';

interface SendWelcomeJobData {
  userId: string;
  email: string;
}

@Injectable()
export class MailService {
  constructor(
    @InjectQueue('mail') private readonly mailQueue: Queue<SendWelcomeJobData>,
  ) {}

  async sendWelcomeEmail(userId: string, email: string): Promise<void> {
    await this.mailQueue.add(
      'send-welcome',
      { userId, email },
      {
        attempts: 3,
        backoff: { type: 'exponential', delay: 2000 },
        removeOnComplete: { count: 1000 },
        removeOnFail: { count: 5000 },
      },
    );
  }
}

Quelques points importants ici. Le typage générique Queue<SendWelcomeJobData> propage le type des données à job.data côté worker — un détail qui évite les erreurs sournoises au runtime. Les options attempts et backoff activent les retries avec un délai exponentiel : très utile quand le job dépend d'une API tierce qui peut renvoyer un 503 ponctuel. Enfin, removeOnComplete et removeOnFail empêchent Redis de gonfler à l'infini en gardant uniquement les N derniers jobs.

Écrire un worker (processor)

Le worker, c'est le code qui consomme les jobs. Avec NestJS, on utilise le décorateur @Processor :

import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';

interface SendWelcomeJobData {
  userId: string;
  email: string;
}

@Processor('mail', { concurrency: 5 })
export class MailProcessor extends WorkerHost {
  private readonly logger = new Logger(MailProcessor.name);

  async process(job: Job<SendWelcomeJobData>): Promise<void> {
    this.logger.log(`Processing job ${job.id} for ${job.data.email}`);
    // appel SMTP, Resend, Mailgun, etc.
    await this.sendEmail(job.data);
  }

  @OnWorkerEvent('completed')
  onCompleted(job: Job): void {
    this.logger.log(`Job ${job.id} completed`);
  }

  @OnWorkerEvent('failed')
  onFailed(job: Job, err: Error): void {
    this.logger.error(`Job ${job.id} failed: ${err.message}`);
  }

  private async sendEmail(data: SendWelcomeJobData): Promise<void> {
    // implementation
  }
}

L'option concurrency: 5 permet au worker de traiter 5 jobs en parallèle au sein du même process. À ajuster selon la nature du job : pour un job CPU-bound, restez à 1 ; pour un job I/O-bound (appel HTTP, mail), 5 à 20 est confortable. Si une exception est levée dans process, BullMQ déclenche automatiquement un retry selon la stratégie de backoff configurée — vous n'avez rien à faire.

Jobs planifiés et récurrents

BullMQ permet aussi de planifier des jobs dans le futur, ou de manière récurrente avec une expression cron :

// Job dans 10 minutes
await this.mailQueue.add('reminder', { userId }, { delay: 10 * 60 * 1000 });

// Job toutes les heures
await this.mailQueue.add(
  'cleanup',
  {},
  {
    repeat: { pattern: '0 * * * *' },
    jobId: 'hourly-cleanup', // jobId fixe = pas de duplication
  },
);

Petit piège classique : si vous appelez add avec un job récurrent à chaque démarrage de l'app sans fixer de jobId, vous accumulez des copies du job dans Redis. Toujours utiliser un jobId stable pour les jobs récurrents, ou les déclarer une seule fois dans un OnModuleInit.

Monitoring avec Bull Board

Naviguer dans Redis pour comprendre ce qu'il se passe est pénible. Bull Board offre une UI minimaliste qui liste les queues, les jobs actifs, en attente, échoués, avec accès aux payloads et stack traces. Indispensable en production.

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

const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');

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

app.use('/admin/queues', serverAdapter.getRouter());

Pensez à protéger cette route derrière une authentification — l'UI expose les payloads des jobs, qui contiennent souvent des données sensibles (emails, identifiants utilisateurs).

Bonnes pratiques en production

Quelques règles que j'applique systématiquement. Un job doit être idempotent : il peut être relancé plusieurs fois (retry, redémarrage), donc le code doit gérer le cas "ce travail a déjà été fait" sans planter ni dupliquer (par exemple, vérifier en base si l'email a déjà été envoyé avant de l'envoyer). Limitez la taille des données dans job.data : passez des IDs et rechargez l'entité depuis MongoDB côté worker, plutôt que de sérialiser un document complet de plusieurs Ko. Déployez le worker séparément de l'API dès que la charge le justifie : un même AppModule peut tourner en mode "API uniquement" ou "worker uniquement" via une variable d'environnement, ce qui permet de scaler indépendamment. Et monitorez les jobs failed avec une alerte (Sentry, Datadog) — un job qui échoue silencieusement, c'est une feature qui ne marche plus sans que personne ne le sache.

Conclusion

BullMQ apporte à NestJS exactement ce qu'il manque côté traitement asynchrone : une file de jobs persistante, retryable, schedulable et observable, sans nécessiter une infra dédiée — un Redis suffit. En quelques heures de setup, vous transformez votre API monolithique en un système robuste qui survit aux pannes des services externes et qui scale horizontalement sans douleur. Combiné avec un typage strict côté job.data et un Bull Board protégé, c'est une brique que je mets en place dès qu'un projet dépasse le stade du MVP.

Vous avez un projet qui mêle API NestJS, traitements lourds ou intégrations externes ? N'hésitez pas à me contacter pour en discuter, je serai ravi de vous accompagner sur l'architecture.