Workers y colas: como sacamos los procesos pesados de la app principal
Caso practico de como delegamos generacion de contenido con IA, video e imagenes a workers dedicados con Redis y BullMQ para que la app principal no se bloquee bajo carga.
El problema real: async no alcanza
Estamos construyendo una app con mi socio que tiene varios procesos que toman tiempo: generacion de contenido con LLMs, generacion de video, procesamiento de imagenes y research profundo con multiples llamadas encadenadas. Al principio asumimos que con async/await el problema estaba resuelto. Node.js es asincrono, no bloquea, todo bien.
La realidad fue distinta. Bajo carga real, la app se volvia irresponsiva. Endpoints simples como login o GET de perfil empezaban a devolver timeouts. Los procesos pesados no solo tardaban ellos mismos, sino que degradaban todo lo demas que corria en el mismo proceso.
El diagnostico: el proceso de Node.js estaba consumiendo todos los recursos disponibles entre conexiones abiertas, memoria y ciclos de CPU. No era un problema de async mal escrito. Era un problema de arquitectura.
- Generacion de contenido con LLMs: entre 10 y 60 segundos por request, dependiendo del modelo y el largo del output.
- Generacion de video: entre 2 y 10 minutos, con descarga de archivos pesados al final.
- Procesamiento de imagenes: CPU-bound, bloquea directamente el event loop.
- Deep research: 3 a 5 llamadas LLM encadenadas con procesamiento intermedio, hasta varios minutos en total.
App manejando todo en proceso: el problema de la saturacion
Renderizando grafico...
Cuando multiples usuarios disparan procesos pesados en simultaneo, el proceso de Node.js consume todos sus recursos y los endpoints simples empiezan a fallar.
Por que async no significa gratis
El misconception mas comun: si uso await, el hilo queda libre. Eso es parcialmente cierto para I/O liviano como queries a base de datos. Cuando el driver de base de datos responde en 5ms, el event loop hace otras cosas mientras espera. Pero no es lo que pasa con nuestros procesos pesados.
Una llamada a un LLM que tarda 45 segundos tiene una conexion HTTP abierta durante 45 segundos. Esa conexion consume un socket del pool. Si tenemos 10 requests LLM concurrentes, tenemos 10 sockets ocupados, buffers de memoria para las respuestas parciales que van llegando por streaming, y cuando el response llega completo hay procesamiento de JSON que puede ser CPU-bound. El event loop no esta libre, esta manejando todo eso.
El procesamiento de imagenes es peor: es directamente CPU-bound. Mientras el evento de procesamiento corre, el event loop esta bloqueado y ningun otro callback puede ejecutarse. Para Node.js, es como si todo el servidor se pausara.
- Sockets del pool de conexiones: cada request HTTP abierta ocupa uno hasta que termina.
- Memoria para buffers: las respuestas grandes (LLMs, archivos) se acumulan en memoria durante la transferencia.
- Slots de conexion a DB: si el worker necesita guardar resultados intermedios, ocupa conexiones adicionales.
- Tiempo de CPU: parsing de JSON grande, manipulacion de imagenes y texto bloquean el event loop directamente.
| Tipo de operacion | Bloquea event loop? | Consume sockets? | Impacto bajo carga |
|---|---|---|---|
| Query a DB (5ms) | No | Si, por 5ms | Casi nulo |
| Llamada LLM (45s) | No (pero ocupa socket 45s) | Si, por 45 segundos | Alto: agota el pool de conexiones |
| Procesamiento de imagen | Si (CPU-bound) | No | Critico: pausa todo el event loop |
| Deep research (5 LLM encadenados) | No (pero 5 sockets en serie) | Si, en serie por minutos | Muy alto: acapara recursos por minutos |
I/O liviano vs operaciones pesadas en el event loop
Renderizando grafico...
No todas las operaciones async son iguales. El I/O liviano libera el event loop en milisegundos. Las operaciones pesadas ocupan recursos por segundos o minutos.
Nuestro caso concreto: que procesos nos bloquean
Nuestra app tiene cuatro categorias de procesos pesados. Cada una bloquea de una forma distinta y tiene un perfil de recursos diferente. Entender esto fue clave para decidir como separarlo.
La generacion de contenido con LLMs es la mas frecuente. Cada request genera texto largo: articulos, analisis, reportes. Los modelos mas capaces pueden tardar entre 30 y 60 segundos en completar, y la respuesta puede ser varios KB de JSON. El deep research encadena 3 a 5 de estas llamadas con procesamiento intermedio entre cada una, lo que significa que una sola operacion de research puede tener el socket ocupado por 3 a 5 minutos en total.
La generacion de video es la mas lenta. Hacemos llamadas a APIs externas que tardan entre 2 y 10 minutos en responder. Despues hay que descargar el archivo resultante, que puede pesar varios cientos de MB. El procesamiento de imagenes en cambio es rapido pero CPU-bound: redimensionar, comprimir, convertir formatos. No dura mucho pero bloquea el event loop mientras corre.
- Generacion LLM: 10-60s por llamada, respuestas de varios KB, alta frecuencia de uso.
- Deep research: 3-5 llamadas LLM encadenadas + procesamiento intermedio, hasta 5 minutos por operacion.
- Generacion de video: 2-10 minutos de espera + descarga de archivos pesados, baja frecuencia pero muy bloqueante.
- Procesamiento de imagenes: segundos de duracion pero CPU-bound, bloquea el event loop directamente.
Deep research bloqueando la app mientras otros requests esperan
Renderizando grafico...
Un solo request de deep research ocupa recursos de la app durante minutos. Requests simples de otros usuarios quedan en cola esperando.
El patron worker/queue: delegar y liberar
La solucion es conceptualmente simple: la app principal deja de ejecutar el trabajo pesado. Cuando llega un request de generacion de video, la app encola un job en Redis y responde al cliente inmediatamente con un job ID. El cliente puede consultar el estado despues. Workers dedicados en procesos separados consumen la queue y hacen el trabajo real.
El cambio de paradigma es importante: en vez de request-response sincrono, el flujo se vuelve asincrono desde el punto de vista del cliente. La app dice 'recibi tu pedido, te aviso cuando este listo' en vez de 'esperame que lo proceso ahora'. Esto libera completamente el proceso principal.
- App principal libre: nunca mas espera resultados de procesos pesados, solo encola y responde.
- Workers aislados: si el worker de video crashea, la app principal y los otros workers siguen funcionando.
- Fallas contenidas: los errores de procesamiento no afectan la disponibilidad de la app.
- Scaling independiente: si video es el cuello de botella, agrego mas workers de video sin tocar nada mas.
Nueva arquitectura: app liviana + workers dedicados
Renderizando grafico...
La app principal solo gestiona la cola. Cada tipo de proceso tiene su propio worker dedicado que escala y falla de forma independiente.
Arquitectura con Redis + BullMQ
BullMQ es una libreria de queues para Node.js construida sobre Redis. Tiene dos actores principales: Queue y Worker. La Queue es el productor: la app principal crea una instancia de Queue y llama a queue.add() con el payload del job. BullMQ persiste el job en Redis con un ID unico y lo pone en estado 'waiting'.
El Worker es el consumidor: corre en un proceso separado, se conecta a la misma Queue de Redis y procesa jobs de a uno (o de a N con concurrency configurada). Cuando termina, marca el job como 'completed' y emite un evento. Si falla, lo marca como 'failed' y BullMQ puede hacer retry automaticamente.
El estado del job vive en Redis en todo momento. Esto significa que la app principal puede consultar el estado de cualquier job en cualquier momento solo con el job ID, sin necesidad de coordinacion directa con el worker.
- Queue: instancia en la app principal, solo agrega jobs. No procesa nada.
- Worker: proceso separado, consume jobs de la queue, ejecuta la logica real.
- Job: unidad de trabajo con ID, payload, estado y resultado.
- Estados: waiting, active, completed, failed, delayed.
- Eventos: completed, failed, progress; la app puede escucharlos via Redis.
Lifecycle completo de un job en BullMQ
Renderizando grafico...
El estado del job vive en Redis durante todo el proceso. La app puede consultarlo en cualquier momento con el job ID sin coordinacion directa con el worker.
Escalar workers de forma independiente
Una de las ventajas mas concretas del patron: el scaling es quirurgico. Si la queue de video tiene 50 jobs acumulados pero AI y Research andan bien, levanto mas workers de video. La app principal no cambia, los otros workers no cambian. Solo agrego mas consumidores de esa cola especifica.
Con Docker Compose es casi trivial: cada tipo de worker es un servicio, y el scaling se hace con replicas. El worker de video puede correr en instancias con mas memoria y mejor red para las descargas. El worker de imagenes puede correr en instancias con mas CPU. La concurrency interna de BullMQ permite ademas que un solo proceso worker procese N jobs en paralelo.
- Scaling horizontal: agregar instancias del worker especifico que esta saturado.
- Concurrency por worker: BullMQ permite configurar cuantos jobs procesa en paralelo cada instancia.
- Recursos diferenciados: workers de video con mas red, workers de imagenes con mas CPU.
- Docker Compose replicas: escalar es cambiar un numero, no redesplegar la app principal.
Scaling independiente por tipo de worker
Renderizando grafico...
Video es el cuello de botella: corremos 4 instancias. AI y Research con 2 cada uno. Images con 1. La app principal ni se entera del scaling.
Como sabe el frontend que el job termino
El cliente lanzo un proceso pesado y recibio un job ID. Ahora necesita saber cuando termino. Hay tres enfoques con tradeoffs distintos y nosotros pasamos por los tres en distintos momentos.
Empezamos con polling: el frontend hace GET /jobs/:id cada N segundos y la app principal consulta el estado en Redis. Simple de implementar, funciona, pero genera carga innecesaria en el servidor y la latencia de notificacion depende del intervalo. El upgrade natural fue SSE (Server-Sent Events): la app mantiene una conexion HTTP abierta y pushea el evento cuando el job completa. Latencia casi cero, mucho menos carga que polling. Webhooks los usamos para integraciones service-to-service donde no hay un frontend que espere.
- Polling: GET periodico al endpoint de status. Simple pero ineficiente bajo carga.
- SSE: conexion HTTP larga, el servidor pushea cuando el job completa. Mas eficiente, latencia minima.
- Webhooks: el servidor llama a una URL configurada cuando el job termina. Ideal para integraciones backend-backend.
| Estrategia | Complejidad | Latencia de notificacion | Carga en servidor | Uso tipico |
|---|---|---|---|---|
| Polling | Baja | Depende del intervalo (1-30s) | Alta (muchas requests) | MVP, casos simples |
| SSE | Media | Casi instantanea | Media (conexion persistente) | Frontend esperando resultado |
| Webhooks | Media | Casi instantanea | Baja (solo 1 request al completar) | Integraciones backend-backend |
Errores, retries y dead letter queues
Las APIs externas fallan. Mucho. Rate limits, timeouts, errores 500 esporadicos, servicios caidos por mantenimiento. En nuestra experiencia, las APIs de generacion de video son especialmente inestables. Sin una estrategia de retry solida, los jobs fallarian de forma permanente ante cualquier error transitorio.
BullMQ tiene retry built-in: cuando un job falla, se puede configurar cuantos reintentos hacer y con que estrategia de backoff. Usamos backoff exponencial para la mayoria de los workers: el primer retry a los 30 segundos, el segundo a los 2 minutos, el tercero a los 10 minutos. Esto le da tiempo a las APIs externas de recuperarse sin martillarlas.
Los jobs que agotan todos los reintentos van a la Dead Letter Queue: una cola separada donde quedan los jobs fallidos para revision manual o reprocesamiento. Tenemos un worker de monitoreo que alerta cuando algo llega a la DLQ.
- Intentos configurables por tipo de job: AI con 3 reintentos, video con 5 (mas inestable).
- Backoff exponencial: 30s, 2min, 10min, 1h. Le da tiempo a las APIs de recuperarse.
- Timeout por job: jobs de video con timeout de 15 minutos para no quedar colgados.
- Dead Letter Queue: jobs fallidos van a una cola separada para analisis y reprocesamiento manual.
Flujo de retry y dead letter queue
Renderizando grafico...
El backoff exponencial protege las APIs externas de ser martilladas en cadena. La DLQ garantiza que ningun job se pierde silenciosamente.
Conexion con la migracion progresiva
Lo que describimos en este post es exactamente el patron del post anterior aplicado a un caso real. La app arranco como un monolito haciendo todo: endpoints de producto, autenticacion, generacion de contenido, procesamiento de archivos. El monitoreo de trafico (guardando metricas en DB) mostro rapidamente que los procesos pesados eran el cuello de botella: concentraban la mayor parte del tiempo de respuesta y degradaban todo lo demas.
En vez de redisenar toda la arquitectura de una vez, extrajimos primero los procesos mas impactantes: generacion AI y deep research. Estabilizamos, monitoreamos. Despues video. Despues imagenes. Cada extraccion fue un ciclo pequeno y controlado. El monolito se fue achicando, cada worker se fue estabilizando, y hoy la app principal es significativamente mas liviana y confiable.
- Monitorear: identificar que procesos concentraban el mayor tiempo de respuesta.
- Detectar: los procesos pesados degradaban todos los endpoints, no solo los propios.
- Extraer: primero AI (mayor frecuencia), despues video (mayor bloqueo), despues imagenes.
- Estabilizar: cada worker en produccion antes de arrancar el siguiente ciclo de extraccion.
Evolucion de la arquitectura: antes y despues
Renderizando grafico...
La migracion no fue un evento unico sino un ciclo repetido: monitorear, detectar el cuello mas impactante, extraer, estabilizar. Misma logica del post anterior, caso real.
Fuentes
- BullMQ DocumentationDocumentacion oficial de BullMQ: queues, workers, retry, eventos y configuracion.
- BullMQ: Going to ProductionGuia de produccion de BullMQ: retry strategies, concurrency, monitoreo y mejores practicas.
- Node.js: Event Loop, Timers y process.nextTickExplicacion oficial del event loop de Node.js y como maneja operaciones asincronas.
- Redis DocumentationDocumentacion oficial de Redis, la base de persistencia de BullMQ.
- Martin Fowler: MonolithFirstEl argumento de arrancar monolitico y extraer cuando hay evidencia. Base conceptual de la migracion progresiva.