Construyendo sistemas fiables de tareas en segundo plano con BullMQ y Redis
Aprenda a diseñar pipelines de tareas en segundo plano resilientes con Node.js utilizando BullMQ y Redis, abordando temas como las reintentos, la concurrencia, la idempotencia y el monitoreo.
Enviar un correo de confirmación, generar un informe, procesar un pago: hay muchas tareas en el backend que no necesitan completarse antes de poder responder a un usuario. Esta guía explica cómo crear sistemas fiables para tareas en segundo plano utilizando BullMQ junto con Redis.
Imagínese un backend donde casi todas las tareas se realizan directamente dentro del ciclo de solicitudes HTTP. ¿Necesita enviar un correo? Hágalo allí mismo. ¿Debe generar un PDF? Lo mismo. ¿Tiene que procesar algunos datos en segundo plano? También hágalo de forma integrada.
Este enfoque funciona bien al principio. Luego deja de funcionar.
La API comienza a ralentizarse. Las solicitudes empiezan a caducar. Y si algún servicio externo falla, toda la solicitud puede fracasar junto con él.
Es en ese momento cuando los tareas en segundo plano comienzan a tener sentido.
En lugar de obligar a la API a completar cada paso antes de responder, puede colocar el trabajo en una cola y dejar que un proceso dedicado lo maneje por separado.
Una opción sólida para esto en el ecosistema de Node.js es BullMQ, respaldado por Redis para el almacenamiento. Así es como se integran las partes.
1. ¿Qué es un trabajo en segundo plano?
Un trabajo en segundo plano es cualquier unidad de trabajo que no necesita ejecutarse de forma síncrona como parte de una solicitud HTTP.
Considere un flujo típico de registro. Cuando alguien crea una cuenta, la API podría necesitar:
- Crear el registro del usuario
- Enviar un correo de bienvenida
- Generar un PDF de bienvenida
- Enviar una notificación
- Actualizar algún otro sistema posterior
Podría intentar ejecutar todo esto de forma inmediata antes de responder:
Client
↓
API
↓
Create User
↓
Send Email
↓
Generate PDF
↓
Send Notification
↓
Response
Pero eso obliga al usuario a esperar a que se completen todos esos pasos.
Un enfoque mejor sería este en su lugar:
Client
↓
API
↓
Create User
↓
Add Job to Queue
↓
Response
Y por separado:
Queue
↓
Worker
↓
Send Email
↓
Done
Dado que la API ya no necesita completar todas las tareas antes de responder, lo hace mucho más rápido.
2. ¿Por qué necesitamos una cola?
Supongamos que enviar un correo electrónico lleva 1 segundo, generar un PDF lleva 2 segundos y llamar a otra API lleva 1 segundo. El endpoint podría quedar bloqueado durante varios segundos antes de enviar una respuesta, lo cual es una mala experiencia para el usuario.
Peor aún, ¿qué sucede si el proveedor de correos electrónicos no es accesible? La solicitud podría fallar aunque la creación del usuario haya tenido éxito. Esa es una dependencia innecesaria entre dos aspectos no relacionados.
Una cola rompe esa vinculación:
┌──────────────┐
│ Node API │
└──────┬───────┘
↓
Add Job
↓
┌──────────────┐
│ Redis │
│ Queue │
└──────┬───────┘
↓
┌──────────────┐
│ Worker │
└──────┬───────┘
↓
Email / PDF / API / etc.
Con esta separación, la API y la tarea en segundo plano tienen cada una una responsabilidad distinta.
3. ¿Qué es BullMQ?
BullMQ es una biblioteca de colas para Node.js que se basa en Redis para almacenar y coordinar tareas. Su arquitectura, a un nivel alto, se ve así:
Producer
↓
Queue
↓
Worker
↓
Job Processing
El productor es el que crea las tareas. La cola las almacena. El trabajador es el que realmente las procesa.
Por ejemplo:
await emailQueue.add("welcome-email", {
userId: user.id,
email: user.email
});
En esencia, la API dice lo siguiente:
"Aquí hay algo que necesita hacerse."
No necesita realizar esa tarea por sí misma.
4. Crear una cola
Así es como se ve una configuración mínima de BullMQ:
import { Queue } from "bullmq";
const connection = {
host: "localhost",
port: 6379
};const emailQueue = new Queue("email", {
connection
});
A partir de ahí, puedes agregar tareas a ella:
await emailQueue.add("welcome-email", {
userId: "123",
email: "user@example.com"
});
Redis se encarga de almacenar todo el estado relacionado con la cola en segundo plano. Conceptualmente, puedes pensar en ello de esta manera:
email queue
Job 1
Job 2
Job 3
Job 4
Job 5
Luego, el trabajador recoge y procesa estos trabajos.
5. Creación de un trabajador
El trabajador es la parte que realmente realiza el trabajo:
import { Worker } from "bullmq";
const worker = new Worker(
"email",
async (job) => {
console.log("Processing:", job.name); await sendWelcomeEmail(
job.data.email
);
},
{
connection
}
);
Al combinarlo todo, el flujo ahora se ve así:
API
↓
emailQueue.add()
↓
Redis
↓
Worker
↓
sendWelcomeEmail()
La API no necesita esperar a que termine de enviarse el correo electrónico; ese es el principal beneficio que ofrecen los trabajos en segundo plano.
6. ¿Qué sucede cuando un trabajo falla?
Es precisamente aquí donde una cola comienza a demostrar su verdadera ventaja frente a una simple llamada de servicio.
Imagínese esta configuración:
API
↓
Email Service
↓
ERROR
Cuando llama directamente a una API, se ve obligado a tomar una decisión inmediata respecto al fallo.
Una cola le brinda otra opción: el trabajo puede simplemente intentarse de nuevo.
Aquí hay un ejemplo:
await emailQueue.add(
"welcome-email",
{
email: "user@example.com"
},
{
attempts: 3
}
);
Con esta configuración, se permiten varios intentos antes de rendirse.
Visualmente, el flujo se ve así:
Attempt 1
↓
Failed
↓
Attempt 2
↓
Failed
↓
Attempt 3
↓
Success
Este patrón es extremadamente útil cuando se trabaja con servicios de terceros poco fiables.
No obstante, las reintentos no deben ser ilimitados ni realizarse de forma descuidada.
Evite una configuración en la que una tarea siga reintentando indefinidamente sin límites.
7. Reintentos con retroceso
Supongamos que un servicio externo deja de funcionar temporalmente.
Lo que se debe evitar es algo como esto:
FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY
FAIL
RETRY IMMEDIATELY
Intentar reintentar constantemente un servicio con problemas puede empeorar la situación.
La solución es introducir un retroceso entre los intentos.
Por ejemplo:
await emailQueue.add(
"welcome-email",
{
email: "user@example.com"
},
{
attempts: 5,
backoff: {
type: "exponential",
delay: 5000
}
}
);
Así es como se ve conceptualmente:
Attempt 1 → Fail
↓
5 sec
↓
Attempt 2 → Fail
↓
10 sec
↓
Attempt 3 → Fail
↓
20 sec
↓
Attempt 4 → Success
El momento preciso depende de cómo configure la estrategia de reintentos y retrocesos.
Pero la idea fundamental sigue siendo la misma:
Dale a los fallos temporales espacio para recuperarse antes de intentarlo de nuevo.
8. Tareas diferidas
No todas las tareas necesitan ejecutarse en el momento en que se crean.
Por ejemplo:
Enviar un recordatorio 24 horas después del registro.
BullMQ te permite programar una tarea para que se ejecute más tarde.
await emailQueue.add(
"reminder",
{
userId: "123"
},
{
delay: 24 * 60 * 60 * 1000
}
);
Conceptualmente:
Create Job
↓
Wait 24 hours
↓
Worker processes job
Este patrón se presenta en situaciones como:
- Correos de recordatorio
- Notificaciones programadas
- Vencimiento de pruebas
- Recordatorios de pago
- Mensajes de seguimiento
9. Múltiples trabajadores
Imagina ahora un sistema que recibe miles de tareas por minuto.
Un único proceso trabajador podría no poder mantener el ritmo.
Puedes escalar añadiendo varios trabajadores al mismo tiempo:
Redis Queue
↓
┌──────────┼──────────┐
↓ ↓ ↓
Worker 1 Worker 2 Worker 3
↓ ↓ ↓
Jobs Jobs Jobs
Cada uno recupera tareas de la cola de forma independiente.
Por ejemplo, dado:
1000 email jobs
Puede que veas algo como:
Worker 1 → Job 1, 4, 7...
Worker 2 → Job 2, 5, 8...
Worker 3 → Job 3, 6, 9...
Agregar más trabajadores es una forma de aumentar el rendimiento.
Pero ten cuidado:
Introducir más trabajadores en el problema no siempre es una solución.
Tu base de datos, tu proveedor de correo electrónico, tu CPU, tu memoria y cualquier servicio posterior tienen sus propios límites de capacidad.
10. Concorrencia
Más allá de ejecutar múltiples procesos trabajadores, BullMQ también te permite configurar cuántas tareas maneja un único trabajador al mismo tiempo.
Por ejemplo:
const worker = new Worker(
"email",
async (job) => {
await sendEmail(job.data.email);
},
{
connection,
concurrency: 5
}
);
Esto permite que un trabajador procese varias tareas en paralelo.
Conceptualmente:
Worker
├── Job 1
├── Job 2
├── Job 3
├── Job 4
└── Job 5
Una mayor concurrencia puede elevar el rendimiento.
Pero no aumente la concurrencia a 100 sin pensar bien las consecuencias.
Si cada tarea accede a su base de datos, una alta concurrencia podría sobrecargarla fácilmente.
Los ajustes de concurrencia deben configurarse de acuerdo con lo que su carga de trabajo realmente puede soportar.
11. Limitación de velocidad
A veces el cuello de botella no está en su propio sistema, sino en el servicio de terceros del que depende.
Supongamos que su proveedor de correo le impone un número fijo de solicitudes por segundo.
Si de repente tiene:
10,000 jobs
no quiere enviarlas todas de una sola vez.
Una cola puede regular la velocidad a la que se procesan las tareas.
La arquitectura resultante se ve así:
10,000 Jobs
↓
Queue
↓
Rate Limit
↓
Worker
↓
External API
Esto es mucho más seguro que enviar miles de solicitudes simultáneas al proveedor.
12. La idempotencia de las tareas es importante
Considere una tarea de procesamiento de pagos:
Process Payment
El trabajador la ejecuta.
El pago se procesa con éxito.
Pero justo antes de que el trabajador la marque como completada, el proceso se cae.
La cola, haciendo exactamente lo para lo que fue diseñada, vuelve a intentar ejecutar la tarea.
Sin medidas de protección, podría ocurrir que se cobre al cliente una segunda vez.
Eso es un problema real y costoso.
Para evitarlo, las tareas deben diseñarse de manera idempotente siempre que sea factible.
En la práctica, eso significa que ejecutar la misma tarea dos veces no debe generar efectos secundarios duplicados no deseados.
Un enfoque común es utilizar como clave una referencia de pago única:
payment:order_123
Luego, antes de realizar cualquier acción, verifique:
Has this payment already been completed?
↓
Yes → Don't charge again
↓
No → Process payment
BullMQ en sí no cuenta con un mecanismo integrado para esto.
Es responsabilidad del código de su aplicación garantizar la idempotencia.
13. Las tareas fallidas necesitan una estrategia
No todos los fallos son iguales, y no todos merecen ser intentados nuevamente.
Considere algunos ejemplos:
Invalid email
Invalid user ID
Missing database record
Invalid payment information
Ejecutar estas tareas cinco veces más no solucionará nada.
Es útil clasificar los fallos en dos categorías:
Fallos temporales
Estos incluyen situaciones como:
- Un tiempo de espera de red
- Una dependencia que está temporalmente fuera de servicio
- Una conexión a la base de datos interrumpida
Estos son los tipos de problemas en los que realmente tiene sentido intentarlo de nuevo más tarde.
Fallos permanentes
Estos incluyen situaciones como:
- Datos de entrada inválidos
- Un recurso al que se hace referencia y que ya no existe
En estos casos, intentar de nuevo es inútil: la tarea debe dirigirse directamente a algún tipo de ruta de manejo de fallos.
Una configuración de cola bien diseñada no se limita a seguir una regla general como:
Retry everything
En lugar de eso, sigue un flujo más deliberado:
Understand why it failed
↓
Temporary?
/ \
YES NO
↓ ↓
Retry Handle failure
14. Manejo de tareas fallidas o sin respuesta
No importa cuán cuidadoso sea, algunas tareas fallarán de manera que no pueda corregirse con intentos repetidos. Necesita tener visibilidad de esas tareas para que no simplemente desaparezcan.
Por ejemplo, podría encontrarse con algo como:
Failed Jobs
──────────────
Job 101 → Email invalid
Job 102 → Payment failed
Job 103 → API timeout
Una vez que pueda ver estos fallos, tendrá opciones:
- Registrar el fallo para su revisión posterior
- Notificar a su equipo
- Permitir que alguien intente de nuevo manualmente
- Corregir los datos defectuosos que causaron el problema
- Dirigir la tarea a un flujo de trabajo dedicado al manejo de fallos
La forma exacta en que se construye esto depende de las necesidades de su sistema. Lo más importante es un único principio:
Las tareas fallidas nunca deben desaparecer sin dejar rastro.
15. Cola vs Tarea Cron
Es fácil confundir estos dos, pero resuelven problemas diferentes.
La función de una tarea Cron es indicar:
"Ejecutar esta tarea a una hora específica."
La función de una cola es indicar:
"Procesar esta unidad de trabajo."
En la práctica, estas dos herramientas suelen funcionar bien juntas. Por ejemplo:
Cron
↓
Find users whose trial expires today
↓
Create jobs
↓
Queue
↓
Workers
↓
Send emails
Esto mantiene la lógica de programación separada de la lógica de procesamiento. Por lo general, es un diseño más limpio que tener un único proceso Cron intentando hacer todo el trabajo por sí mismo.
16. Eventos de cola y monitoreo
Una vez que lo esté ejecutando en producción, necesitará tener visibilidad de lo que realmente está sucediendo dentro de la cola.
Las métricas que vale la pena monitorear incluyen:
- Tareas esperando ser procesadas
- Tareas que se están procesando actualmente
- Tareas que se completaron con éxito
- Tareas que fallaron
- El tiempo que lleva el procesamiento
- Cuántas reintentos se están realizando
- Tamaño total de la cola
Imagínese un panel de control que de repente muestre algo como esto:
Waiting Jobs
Normal: 50
Current: 25,000
Ese tipo de aumento repentino es una señal de alerta. Podría significar:
- Sus procesos de trabajo han dejado de ejecutarse
- Una API externa se ha ralentizado
- Su base de datos está bajo una carga elevada
- El tráfico ha aumentado drásticamente
- Una implementación reciente introdujo un error
Si no monitorea su cola, estos problemas pueden acumularse de forma invisible hasta que los usuarios se den cuenta de que algo no está bien.
17. No ponga todo en una cola
Tener BullMQ disponible no significa que toda operación deba realizarse como tarea en segundo plano.
Tomemos algo como esto:
GET /profile
Aquí, el usuario espera recibir sus datos de perfil de inmediato. Postergar eso a una cola en segundo plano no tendría sentido, ya que solo añadiría un retraso innecesario.
Una cola tiene sentido cuando:
- La tarea tarda en completarse
- La tarea puede realizarse de forma asíncrona
- Puede ser necesario intentarla nuevamente
- La tarea consume muchos recursos
- Depende de servicios externos que no son completamente fiables
- El resultado no necesita formar parte de la respuesta inmediata
Una pregunta útil es:
¿Realmente el usuario necesita este resultado antes de que se envíe la respuesta HTTP?
Si no, vale la pena considerar trasladar esa tarea a un trabajo en segundo plano.
18. Una arquitectura al estilo de producción
Al combinar todo esto, una configuración típica se ve así:
Client
↓
Node.js API
↓
┌──────┴──────┐
↓ ↓
PostgreSQL Redis
↓
Queue
↓
┌──────────┼──────────┐
↓ ↓ ↓
Worker 1 Worker 2 Worker 3
↓ ↓ ↓
Email PDF Notifications
La capa de API se encarga de todo lo que debe realizarse de inmediato. PostgreSQL (o su base de datos preferida) almacena los datos empresariales duraderos. Redis soporta la infraestructura de colas y otras cargas de trabajo de corta duración para las que es adecuado. Los trabajadores se ocupan de todo lo que puede ocurrir de forma asíncrona.
Dividir las responsabilidades de esta manera hace que todo el sistema sea considerablemente más fácil de escalar.
19. Errores que conviene evitar
Error 1: Hacer todo dentro de la solicitud HTTP
Esto conduce a APIs que son tanto lentas como frágiles.
Error 2: Volver a intentarlo sin límites
Algunos fallos simplemente no se resolverán por sí solos, sin importar cuántas veces lo intentes.
Error 3: Omitir la idempotencia
Si una tarea se ejecuta dos veces, puede provocar efectos secundarios duplicados que no tenías en cuenta.
Error 4: Permitir concurrencia ilimitada
Sin límites, corres el riesgo de sobrecargar los sistemas de los que dependen tus tareas.
Error 5: Omitir la supervisión
Una cola que sigue creciendo sin control es un problema operativo listo para manifestarse.
Error 6: Usar Redis como sistema de registro
El estado de la cola y los datos empresariales clave tienen propósitos diferentes y no deben confundirse.
Error 7: Hacer que todo sea asíncrono
Algunas operaciones realmente necesitan completarse antes de poder enviar una respuesta.
20. Un modelo mental mejor
Antes de comprender las colas, el instinto natural es pensar en el manejo de solicitudes de esta manera:
Request
↓
Do everything
↓
Response
Un modelo más útil se ve así en su lugar:
Request
↓
Do what must happen immediately
↓
Queue what can happen later
↓
Response
Seguido de:
Queue
↓
Worker
↓
Process
↓
Retry if appropriate
↓
Complete / Fail
Ese cambio: separar lo que debe ocurrir ahora de lo que puede hacerse más tarde, es la idea central detrás de todo esto.
Conclusión final
BullMQ no es útil simplemente porque sea una biblioteca ampliamente utilizada en Node.js. Es útil porque el procesamiento de tareas en segundo plano resuelve una necesidad arquitectónica real.
Si una tarea es:
- Lenta
- Algo que se puede intentar nuevamente
- Algo que no necesita bloquear la respuesta
- Dependiente de un servicio externo
- Intensiva en recursos
entonces probablemente no debería formar parte del ciclo de solicitudes HTTP.
Una cola proporciona un lugar donde realizar ese trabajo. Redis ofrece la infraestructura subyacente. BullMQ se encarga de la gestión de tareas. Los trabajadores ejecutan el procesamiento real. Las reintentos se ocupan de los fallos temporales. Los ajustes de concurrencia mantienen el rendimiento bajo control. La supervisión permite saber cuándo algo no funciona correctamente. Y un diseño cuidadoso a nivel de aplicación asegura que las tareas puedan ejecutarse con seguridad más de una vez cuando sea necesario.
La lección clave aquí es la siguiente:
No todo tiene que resolverse dentro del ciclo solicitud-respuesta.
A veces, la respuesta adecuada a enviar es simplemente:
"He aceptado el trabajo. Nosotros nos ocuparemos del resto."
Lecturas relacionadas
- Diseño de Backend de Chat en Tiempo Real: Habitaciones, Persistencia y Escalabilidad — Aprenda cómo arquitecturar un backend de chat en tiempo real utilizando Socket.IO, PostgreSQL y Redis, abordando temas como las habitaciones, el orden de persistencia de los mensajes, la presencia de usuarios y la escalabilidad multi-servidor.
- Fundamentos del Cache de Redis: Patrones, Errores y Conceptos en Entrevistas — Conozca cómo funciona el caching con Redis en aplicaciones Node.js, desde estrategias como cache-aside y TTL hasta medidas de protección contra sobrecargas, políticas de eliminación de datos y preguntas comunes en entrevistas.