Running image resizing inside an Express route is a recipe for disaster. A user uploads a ten-megabyte photo, your server starts crunching pixels, and thirty seconds later the request times out. Background job queues exist to prevent exactly this kind of pain. In the Node.js ecosystem, Bull and BullMQ have become the two heavyweights for handling asynchronous work through Redis. They share DNA but diverge sharply in philosophy and day-to-day ergonomics. Picking the right one matters because switching later is not a simple package update.
The Shared Foundation
Both libraries use Redis as their backbone. Redis handles atomic operations, sorted sets for delayed jobs, and pub/sub for events. If you already run Redis for caching or sessions, adding a job queue does not require new infrastructure. Both Bull and BullMQ support priorities, retries with backoff, concurrency controls, and repeatable jobs. That overlap makes the choice harder, not easier. You cannot fall back to a features checklist. Instead, you have to look at how each library wants you to structure your code.
Bull: The Battle-Tested Veteran
Bull has been around for years and runs in thousands of production applications. It works. The API wraps everything into a single Queue instance. You instantiate it, define a processing function, and listen for events all on the same object. This monolithic design feels familiar if you come from older Node.js patterns. Codebases that pre-date widespread async/avenue fit Bull naturally because it grew up alongside callbacks and earlier Redis clients.
The downside is tight coupling. When your API server creates a job, it imports the same Queue object that contains the worker logic. In practice, this means your web process drags in dependencies it never executes. It is not a fatal flaw, but it nags at clean architecture. For simple workloads, you might never notice. For large teams with dozens of modules, the friction accumulates.
BullMQ: A Ground-Up Rebuild
BullMQ is the official successor. It was rewritten in TypeScript from day one, so types are not an afterthought grafted onto JavaScript source. The API splits responsibilities into distinct classes. Queue handles adding jobs. Worker handles processing them. QueueEvents handles observability. This separation mirrors how modern distributed systems actually operate. Your API pods only need the Queue class and a Redis connection. Your worker pods import the Worker class. The boundary is physical, not just conceptual.
This shift pays off in large teams. A developer shipping a new feature can enqueue a job without knowing which file contains the processor. The compiler catches type mismatches between job data and handlers early rather than at runtime. The async/await API also feels native in modern Node.js. You will not find yourself fighting legacy conventions.
Job Flows: From Hacks to First-Class Citizens
Multi-step workflows expose the widest gap between the two libraries.
Suppose you are building an e-commerce invoicing pipeline. A customer checks out. You need to reserve inventory, charge a card, generate a PDF, and send an email. With Bull, chaining these steps means manual bookkeeping. You might have one processor fire off the next job, passing state through Redis or bulky data payloads. You write the parent-child coordination yourself. It works until it does not. Retry logic gets messy. If the PDF step fails, unwinding the charge requires custom compensation code that is easy to get wrong.
BullMQ introduces FlowProducer. You define a tree of jobs where parents automatically wait for their children. In the invoicing example, you create a root job called finalize-order with three children: reserve-inventory, charge-payment, and generate-pdf. You can make the email notification a child of the PDF job. Redis stores the graph structure. The parent only activates when every dependency succeeds. If one child fails, the whole branch halts. You do not write polling loops or recursive job spawners. This is not syntactic sugar. It changes how you model business logic.
Rate Limiting: Blunt Instrument vs. Scalpel
Both libraries can throttle throughput, but the granularity differs enormously.
Bull applique des limites de débit par file d'attente. Si vous configurez une file pour traiter cent tâches par seconde, ce plafond s'applique uniformément à chaque tâche de la file. C'est acceptable pour des charges de travail homogènes. Cela pose problème dans les plateformes SaaS multi-locataires. Imaginez un client bruyant qui déverse un million de livraisons de webhooks dans une file d'attente partagée. La limite au niveau de la file de Bull signifie que vous ne pouvez pas ralentir ce locataire sans ralentir tous les autres. Vos options sont peu élégantes : créer des files Redis distinctes par client et les gérer dynamiquement, ou accepter l'injustice.
BullMQ ajoute une limitation de débit basée sur des groupes. Vous étiquetez chaque tâche avec une clé de groupe, généralement un ID de locataire ou d'utilisateur, et définissez des limites par groupe. La même file traite les tâches de tous les locataires, mais l'ordonnanceur régule chaque groupe indépendamment. Un pic d'activité du client A n'affame pas le client B. Vous évitez l'éparpillement des files d'attente et gardez votre espace de clés Redis bien organisé. Pour les plateformes confrontées au problème du « voisin bruyant », cela peut à lui seul justifier la migration.
Une architecture plus propre en pratique
La séparation entre la file d'attente (Queue) et le processeur (Worker) est subtile jusqu'à ce que vous deviez déboguer un incident en production. Avec Bull, il est courant de voir du code de création de tâches enfoui dans des gestionnaires de routes qui importent également de lourdes dépendances de traitement. BullMQ vous oblige à décider où le travail s'effectue. Vos serveurs web restent légers. Vos conteneurs workers regroupent les bibliothèques lourdes, les processeurs d'images ou les navigateurs sans interface (headless browsers). Si une fuite de mémoire apparaît, vous savez exactement quel type de processus profiler. Le modèle mental se rapproche de systèmes comme Celery ou Sidekiq.
Faire le choix
Commencez avec BullMQ si vous partez sur de nouvelles bases. Les définitions TypeScript sont précises et complètes. Les flux de tâches éliminent des pages entières de code d'orchestration. La limitation de débit par groupe résout les problèmes d'équité avant même qu'ils ne surviennent. L'API async/await semble native. Il y a peu de raisons de choisir l'ancienne bibliothèque pour un nouveau projet.
Restez sur Bull si cela fonctionne déjà. Les migrations coûtent du temps et risquent de compromettre la stabilité. Si vos tâches sont simples et indépendantes, vous ne passez pas à côté de fonctionnalités dont vous avez réellement besoin. Une file d'attente qui envoie des e-mails de réinitialisation de mot de passe et redimensionne des avatars n'a pas besoin de graphes de flux. Réécrire du code qui fonctionne pour une pureté théorique n'est pas de l'ingénierie. C'est de l'amateurisme.
Réalité de la migration
Si vous effectuez la transition, traitez-la comme un changement d'infrastructure et non comme un simple refactoring de code. Bull et BullMQ utilisent des schémas de clés Redis différents. Ils ne peuvent pas lire les données ou l'état des tâches de l'autre. Vous ne pouvez pas simplement activer un flag de fonctionnalité en espérant que les anciennes tâches se terminent. Vous devez vider complètement chaque file existante, déployer les nouveaux workers et commencer la mise en file d'attente avec BullMQ. Prévoyez une fenêtre de maintenance ou un déploiement blue-green où les anciens workers consomment l'ancienne file pendant que les nouveaux workers gèrent la nouvelle.
