Tâches longues en Node.js : traiter vos tâches sans dégrader l'expérience utilisateur avec BullMQ
- Un système de queue permet de décharger les tâches lourdes dans des workers en arrière-plan, sans bloquer le thread principal de l'API.
- La requête HTTP retourne bien un statut 200 et place le traitement lourd dans une queue.
- On a choisi d'utiliser BullMQ pour son intégration avec NestJS et sa simplicité de mise en place et de gestion.
- Cette architecture s'applique à de nombreux cas concrets : analyse IA, OCR, génération de documents, import de données volumineuses.
Les applications web embarquent de plus en plus de tâches qui prennent un certain temps à être traitées. On parle de plusieurs secondes, voire plusieurs minutes. Et ceci est d’autant plus vrai depuis l’arrivée de l’IA au sein des différents services web : OCR, Analyse IA, conversations aux LLMs…
Deux questions se posent alors : comment traiter ces tâches sans bloquer le serveur ? Et comment informer l’utilisateur de leur avancement en temps réel ?
Chez Lonestone, on a adopté une combinaison simple et efficace : un système de queues (BullMQ) couplé à des Server-Sent Events (SSE). On va donc vous détailler l’architecture, les choix techniques, et on livre un exemple complet avec NestJS côté API et React côté front.
Que vos applications embarquent ou non de l’IA, ce dont on va vous parler aujourd’hui s’applique à bien des cas d’usage, et nous sommes convaincus que cela vous sera utile.
Dans ce premier article, nous aborderons le sujet des queues, de leur utilité, des différents choix qui s’offrent à nous et de comment le mettre en place dans NestJS. Par la suite, dans un second article, nous nous concentrerons sur l’utilisation des Server Sent Events et de comment les utiliser pour renvoyer l’information aux différents clients qui consomment notre API.
Le problème des tâches longues
Pourquoi l’utilisation d’un await côté API ne suffit plus
Dans la plupart des cas, lorsque l’on fait une requête HTTP classique vers notre API, l’utilisation de simple await suffit. Les opérations étant généralement I/O et rapides, Node.js peut les gérer efficacement sans bloquer le thread principal.
Cependant, dès qu’on touche à des requêtes HTTP longues ou à des tâches intensives pour le CPU, on se retrouve confronté à des problèmes : Timeout de la requête, couplage fort, scalabilité de l’application… Et en prime, une expérience utilisateur dégradée, car incapable de savoir où en est le traitement de la tâche.
Le couplage fort (tight coupling) entre la requête HTTP et le traitement lourd est la racine du problème. La solution passe par le découplage : déléguer le traitement à un processus en arrière-plan et répondre immédiatement au client.
Deux problèmes distincts à résoudre
En réalité, il y a deux défis différents que l’on doit aborder :
- Exécuter la tâche sans bloquer le serveur/la requête : c’est le rôle du système de queues.
- Notifier le client de l’avancée et du résultat : c’est le rôle des événements côté serveur (SSE).
Concentrons-nous d’abord sur le système de queues pour traiter les tâches.
Les systèmes de queues
Qu’est-ce qu’une job queue ?
Avant de donner des pistes de solutions techniques, il est important de bien comprendre ce qu’est une queue, son fonctionnement et son utilité.
Une queue (ou file d’attente pour ceux qui ont horreur des anglicismes) est une liste de jobs (de tâches) en attente de traitement. Les jobs arrivent un par un dans la file, puis sont distribués à des workers qui les traitent en parallèle.
flowchart LR
A@{ shape: processes, label: "Jobs" } --> B@{ "shape": "database", label: "Queue" }
B --> C@{ shape: lin-rect, label: "Worker 1" }
B --> D@{ shape: lin-rect, label: "Worker 2" }
B --> E@{ shape: lin-rect, label: "Worker 3" }
Le principe est simple : le code qui reçoit la requête HTTP ajoute un job dans la queue et répond immédiatement au client. En arrière-plan, un ou plusieurs workers récupèrent les jobs et les traitent à leur rythme, indépendamment du cycle requête/réponse HTTP.
Avec ceci, on peut déjà régler un de nos problèmes : le couplage fort.
BullMQ vs RabbitMQ : comment choisir
Maintenant il est temps de choisir la solution pour ajouter un système de queue dans vos applications. Et comme tout en informatique, il existe plein de solutions plus ou moins intéressantes selon vos besoins. Ici, nous avons décidé de nous concentrer sur 2 solutions très populaires pour créer des systèmes de queues : BullMQ et RabbitMQ.
| BullMQ | RabbitMQ | |
|---|---|---|
| Utilisation | Conçu pour Node.js. Idéal pour une stack JS/TS unifiée. | Agnostique. Conçu pour la communication entre micro-services (polyglotte). |
| Fonctionnement | Utilise Redis comme backend. Gestion de jobs avec priorité, retries, concurrence configurable. | Message broker pub/sub. Routing avancé entre producteurs et consommateurs. |
| Complexité | Simple à intégrer, surtout avec NestJS (@nestjs/bullmq). | Plus complexe à déployer et configurer (serveur dédié, exchanges, bindings). |
| Cas d’usage | Tâches en arrière-plan dans une application monolithique ou modulaire. | Communication inter-services dans une architecture micro-services. |
Comme vous pouvez le voir, il n’y a pas de bon ou mauvais choix. Si je devais résumer ma vie avec vous, je dirais que c’est d’abord des rencontres. Tout va dépendre de vos besoins, de la complexité de votre système, etc…
Si vous êtes à la recherche d’un système simple et qui fonctionnera à la perfection dans une stack Javascript ou Typescript unifiée, alors BullMQ saura répondre à vos attentes. Mais si vous avez besoin d’un système plus agnostique, idéal pour un fonctionnement en micro-service et plus indépendant, alors RabbitMQ saura sûrement vous séduire.
Pourquoi nous utilisons BullMQ avec NestJS
Chez Lonestone, la stack technique que nous utilisons nous pousse à partir sur BullMQ :
- Premièrement, nous utilisons NestJS qui possède une intégration via le package
@nestjs/bullmq. - Deuxièmement, nos applications sont souvent sans architecture micro-services.
- Troisièmement, la simplicité de BullMQ : L’utilisation de Redis, une base de donnée clé-valeur légère, une configuration relativement simple pour choisir la concurrence, la récurrence du nettoyage, etc… Rien de bien sorcier avec BullMQ.
Bien entendu, vous pouvez fouiller plus en profondeur, chercher d’autres pistes, pour voir s’il existe des alternatives qui pourraient convenir à vos attentes spécifiques. Nous en tout cas, on a porté notre dévolu sur BullMQ.
Place à un exemple : l’analyse du contenu d’un fichier
Faisons une pause dans la théorie, voulez-vous ? Pour rendre cela plus digeste, nous allons faire une première partie de l’exemple uniquement pour le système de queue.
Pour notre exemple, nous allons prendre un cas que nous avons eu l’occasion de rencontrer chez Lonestone : l’analyse du contenu d’un fichier pour en retirer des informations importantes.
L’architecture du système
Comme nous l’avons vu précédemment, nous allons séparer la requête HTTP et l’analyse.
Donc lorsque l’utilisateur fera une requête POST vers l’API, il recevra bien une réponse lui disant si oui ou non la requête est un succès, puis en parallèle, nous ajoutons un job, qui une fois traité lancera l’analyse dans un worker qui fonctionnera indépendamment.
Voici donc le flux de notre système pour le moment :
sequenceDiagram
participant Client
participant API
participant Queue
participant Worker
Client->>API: POST /analyze
API->>Queue: Ajoute un job
API-->>Client: 200 OK
Queue->>Worker: Traite le job
Worker->>Worker: Étape 1 (extraction)
Worker->>Worker: Étape 2 (analyse)
Worker->>Worker: Terminé
Un peu de code : Implémentation d’une queue avec NestJS
Passons au code. L’exemple utilise NestJS avec @nestjs/bullmq pour les queues.
Le code que nous allons vous présenter ici est une version simplifiée de ce que vous pourrez retrouver dans le code complet de l’exemple, disponible sur GitHub. Ce projet étant basé sur la stack technique Lonestone, certains aspects peuvent être plus complexes sur le repository.
Initialisation du module et de la queue
La première étape est de configurer BullMQ pour NestJS. Il est configuré au niveau du module racine avec la connexion Redis :
// app.module.ts
@Module({
imports: [
BullModule.forRoot({
connection: {
host: config.redis.host,
port: config.redis.port,
},
}),
//[...]
],
controllers: [
// [...]
],
providers: [
// [...]
],
})
export class AppModule {}
Ensuite, nous devons ajouter le BullModule dans le module qui nous intéresse et enregistrer la queue avec un nom. Dans notre cas, le point d’entrée est le module AnalysisModule qui connecte tous les éléments :
import { BullModule } from '@nestjs/bullmq'
import { Module } from '@nestjs/common'
import { AnalysisController } from './analysis.controller'
import { ANALYSIS_QUEUE_NAME, AnalysisProcessor } from './analysis.processor'
import { AnalysisService } from './analysis.service'
@Module({
imports: [
BullModule.registerQueue({ name: ANALYSIS_QUEUE_NAME }),
],
controllers: [AnalysisController],
providers: [AnalysisService, AnalysisProcessor],
})
export class AnalysisModule {}
On peut voir 2 étapes importantes ici pour notre système de queue :
BullModule.registerQueue({ name: ANALYSIS_QUEUE_NAME }), qui nous permet d’enregistrer la queue avec le nom que l’on choisit.AnalysisProcessorque nous allons détailler juste après.
Traiter la requête envoyée par l’utilisateur
Tout d’abord nous avons notre AnalysisController qui contient la route permettant de lancer l’analyse.
import { Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'
@Controller('analysis')
export class AnalysisController {
constructor(
private readonly analysisService: AnalysisService,
) {}
@Post('/:id/analyze')
@HttpCode(HttpStatus.OK)
async startAnalyze(@Param('id') id: string) {
return this.analysisService.startAnalyze(id)
}
}
Ce dernier possède une route /:id/analyze qui va appeler la méthode startAnalyze de notre service AnalysisService.
Le service est minimaliste. Il reçoit un identifiant d’analyse et ajoute un job dans la queue BullMQ :
import { InjectQueue } from '@nestjs/bullmq'
import { Injectable } from '@nestjs/common'
import { Queue } from 'bullmq'
@Injectable()
export class AnalysisService {
constructor(
@InjectQueue(ANALYSIS_QUEUE_NAME) private readonly analysisQueue: Queue,
) {}
async startAnalyze(id: string) {
this.analysisQueue.add(ANALYSIS_JOB_NAME, { analysisId: id })
}
}
L’appel à add() est non bloquant : le job est placé dans Redis et le service retourne immédiatement. Le traitement effectif se fera dans le worker.
Le processor (worker) : traiter le job
Le processor est le composant qui effectue le travail lourd. Il hérite de WorkerHost de BullMQ qui attend donc une méthode process permettant de traiter le job. C’est ici qu’on placera les tâches longues que l’on veut séparer de notre requête principale :
import { Processor, WorkerHost } from '@nestjs/bullmq'
import { Job } from 'bullmq'
export const ANALYSIS_QUEUE_NAME = 'analysis_queue'
export const ANALYSIS_JOB_NAME = 'analysis_job'
export const ANALYSIS_JOBS_CONCURRENCY = 10
@Processor(ANALYSIS_QUEUE_NAME, {
concurrency: ANALYSIS_JOBS_CONCURRENCY,
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 24 * 3600 },
})
export class AnalysisProcessor extends WorkerHost {
constructor() {
super()
}
async process(job: Job<AnalysisJobData>) {
await performExtraction(job.data) // Tâche longue
await performAnalysis(job.data) // Tâche longue
}
}
Quelques points importants :
concurrency: 10: jusqu’à 10 jobs sont traités en parallèle par ce worker.removeOnCompleteetremoveOnFail: les jobs terminés ou échoués sont nettoyés automatiquement pour éviter que Redis ne grossisse indéfiniment.
Une petite partie côté front avec React
Déclencher l’analyse
Côté front, on crée un hook de mutation simple : il appelle l’API pour lancer l’analyse. Le retour est immédiat (puisque le serveur ajoute le job en queue et répond tout de suite). Les mises à jour de progression arrivent ensuite via le SSE.
import { useMutation } from '@tanstack/react-query'
export function useStartAnalysis() {
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
const response = await analysisControllerStartAnalyze({
path: { id },
})
if (response.error) throw new Error(response.error as string)
return response.data
},
})
}
À noter que nous utilisons un SDK auto-généré pour nos routes, d’où l’utilisation de analysisControllerStartAnalyze pour notre requête POST sur l’endpoint /:id/analyze.
Lancer une analyse
Dans notre exemple, nous avons un dashboard affichant quelques “analyses”. L’interface du dashboard utilise le hook et affiche une carte par analyse :
export default function DashboardPage() {
const { data: analyses } = useAnalyses()
const { mutate } = useStartAnalysis()
return (
<main className="container mx-auto py-8 px-4 space-y-6">
<h1 className="text-3xl font-bold">Analyses</h1>
{analyses?.map((analysis) => (
<Card key={analysis.id}>
<CardHeader>
<CardTitle>Analysis</CardTitle>
<AnalysisBadge step={analysis.step} />
</CardHeader>
<CardFooter>
<Button
onClick={() => mutate({ id: analysis.id })}
disabled={
analysis.step !== 'completed' && analysis.step !== 'failed'
}
>
Lancer l'analyse
</Button>
</CardFooter>
</Card>
))}
</main>
)
}
On peut donc lancer une analyse pour chaque “analyse” (désolé pour la logique métier, on aura déjà vu des règles métiers plus intelligentes que celle-là).
Mais si vous avez suivi les étapes et que vous avez lancé une analyse, vous avez sûrement remarqué que nous n’avons pas de retour sur l’avancée de notre analyse, ce qui fait que nous ne pouvons pas savoir si une analyse s’est correctement terminée. Tout ce que nous sommes capables de déterminer pour le moment c’est si l’analyse s’est bien lancée ou non (si nous recevons bien une réponse HTTP 200 pour notre requête POST).
En résumé
Pour traiter des tâches lourdes en Node.js, nous avons décidé d’utiliser BullMQ, qui permet une architecture simple et performante :
- BullMQ gère le traitement en arrière-plan via Redis, avec concurrence, retries et nettoyage automatique.
- La requête HTTP retourne immédiatement un statut 200, sans attendre la fin du traitement.
- Les workers traitent les jobs de façon asynchrone, indépendamment du cycle requête/réponse.
Ce système s’adapte à de nombreux cas concrets : analyse par IA, traitement OCR, génération de documents, import de données, envoi d’emails en masse…
Mais une question subsiste : comment prévenir les utilisateurs de l’état d’avancement du traitement ?
À l’heure actuelle, nous sommes capables de répondre aux besoins de performances et de réduire le couplage, mais nous n’avons pas encore vu comment envoyer des informations aux différents clients qui ont envoyé la requête HTTP.
C’est précisément l’objet de notre deuxième article (nous sommes persuadés que vous ne l’aviez pas vu venir) : comment utiliser les Server-Sent Events (SSE) et des principes d’architecture Event-Driven dans NestJS pour tenir les utilisateurs informés en temps réel !
Retrouvez la suite dans notre deuxième article sur le sujet, et le code complet de l’exemple (combinant BullMQ et SSE) sur GitHub.