Implementar BullMQ es fácil. Implementarla bien — con reintentos correctos, colas separadas por dominio, workers configurados por carga y visibilidad real de fallos — eso es lo que diferencia un sistema que sobrevive al tráfico real de uno que se rompe silenciosamente a las 3am.
Este artículo resume lo que aprendí construyendo el sistema de pagos y el motor de procesamiento de Trelk Bot, donde BullMQ procesa operaciones críticas en producción con cero pérdida de datos.
El Problema: Síncronico por Defecto
Cuando un webhook de pago llega, el instinto es procesarlo en el handler HTTP directamente. Funciona en desarrollo. En producción, si el procesador tarda 300ms x 200 requests concurrentes, el event loop se bloquea, el timeout del cliente expira y el pago se considera fallido desde el lado del usuario — aunque el servidor lo completó eventualmente.
La solución no es "más servidores". Es desacoplar la recepción del procesamiento. El HTTP handler solo encola el job y responde 202 Accepted en <5ms. El worker procesa cuando puede.
Arquitectura de Colas por Dominio
Un error común es usar una sola cola para todo. El problema: un job lento de "exportar CSV de 50k filas" bloquea el worker que debería procesar "enviar notificación urgente".
La solución es separar por dominio y criticidad:
payments:webhooks — alta prioridad, concurrencia 5, sin delay
notifications:email — media prioridad, concurrencia 10, backoff exponencial
reports:export — baja prioridad, concurrencia 2, jobs pesados
Cada cola tiene su propio worker con configuración independiente. Si el worker de reportes muere, los pagos siguen procesándose sin interrupción.
Retry con Backoff Exponencial
El retry por defecto de BullMQ es inmediato: si falla, reintenta 3 veces seguidas. En producción esto es un antipatrón — si el fallo es por rate limiting del proveedor externo, 3 reintentos instantáneos solo agravan el problema.
La configuración correcta para un webhook de pago:
attempts: 5, backoff: { type: 'exponential', delay: 2000 }
— Reintento 1 a los 2s, 2 a los 4s, 3 a los 8s, 4 a los 16s, 5 a los 32s.
Con esto, un proveedor con downtime de 30 segundos se recupera solo. Sin pérdida de datos, sin intervención manual.
Dead Letter Queue: Falla con Dignidad
Cuando un job agota todos sus reintentos, BullMQ lo mueve a estado failed. Si no tienes visibilidad sobre eso, el dato se pierde silenciosamente.
Lo que implementé fue un listener global de eventos failed que persiste el job en una tabla failed_jobs en PostgreSQL con el error completo, stack trace, payload original y timestamp. Eso permite:
- Replay manual desde el panel administrativo
- Alertas cuando el volumen de fallos supera un umbral
- Auditoría 100% de lo que entró al sistema
Concurrencia y Recursos
BullMQ ejecuta workers en paralelo según el parámetro concurrency. Más no siempre es mejor — un worker de pagos con concurrencia 50 puede saturar el pool de conexiones de PostgreSQL (por defecto 10 conexiones).
La regla práctica: concurrencia = (pool_size * 0.7) / workers_levantados. Si tienes pool de 20 y 2 workers del mismo tipo, cada uno va con concurrencia 7. Dejas margen para queries ad-hoc y healthchecks.
Lo que Aprendí the Hard Way
- Los jobs deben ser idempotentes. Si el worker procesa el mismo job dos veces (por un restart en mal momento), el resultado debe ser el mismo. Guarda el ID del job y valida antes de procesar.
- El payload del job debe ser mínimo. No metas el objeto completo — mete el ID y el worker lo carga de la BD. Reduce memoria en Redis y evita problemas de serialización.
- Monitorea Redis como primera métrica. Si el lag de la cola crece, tienes un cuello de botella en workers, no en la API. BullMQ Board o Bull Monitor dan visibilidad sin código adicional.