From n00b to ZeroCool / Profesionalización

Colas y workers: el trabajo pesado que nadie ve (hasta que truena)

Guía práctica de colas y workers: idempotencia, retries con backoff, DLQ y monitoreo. Ejemplos con Redis + BullMQ para aguantar producción.

Lo que vale la pena leer aquí

Un martes 9:17 am. Tú apenas vas abriendo la laptop (esa que ya suena como turbina) y cae el mensaje en el grupo: “Oigan, no están llegando correos”.

Un martes 9:17 am. Tú apenas vas abriendo la laptop (esa que ya suena como turbina) y cae el mensaje en el grupo: “Oigan, no están llegando correos”.

La API “responde”, el sitio “carga”, los pagos “parecen” pasar… pero el trabajo que de verdad mueve el negocio —facturas, PDFs, webhooks, sincronizar inventario— vive en el sótano. Ahí es donde entran colas y workers: el stack que jala en silencio… hasta que truena y te cae el deadline.

Qué te vas a llevar

  • Cuándo conviene meter una cola y cuándo es puro overkill.
  • Cómo diseñar jobs con idempotencia, retries y backoff (sin duplicar cobros ni spamear usuarios).
  • Un flujo práctico con Redis + BullMQ (Node.js), con decisiones de producción.
  • Cómo sobrevivir al “se atoró la cola” con DLQ, métricas, logs y alertas.
  • Errores comunes que he visto (y sí, también cometido) para que no pagues esa talacha.

Por qué el backend necesita “sombras”

Una cola separa dos mundos que no deberían pelearse en el mismo request:

  1. El mundo del usuario: latencia baja, respuestas rápidas, “no me hagas esperar”.
  2. El mundo del trabajo pesado: tarda, depende de terceros, se cae, y a veces llega en ráfagas.

Ejemplos bien terrenales:

  • Timbrado/facturación: el PAC se tarda o se muere. Si lo haces síncrono, tu checkout se vuelve ruleta rusa.
  • Correo/SMS/WhatsApp: proveedores con rate limits. Si te avientas 10k en un minuto, te cortan el servicio.
  • Webhooks (Stripe, Mercado Pago, Shopify): llegan en bursts. Si tu endpoint no aguanta, pierdes eventos y luego ni sabes qué pasó.
  • PDFs/ZIPs: CPU y RAM se van al techo y tiras tu app “por un reporte”.

Y dos escenas muy reales:

  • Internet de oficina que “ahorita regresa” y tu worker corriendo en una VM con red inestable. Sin retries con cabeza, pierdes trabajo.
  • Deploy a las 6:55 pm porque “ya se va el cliente”. Reinicia el worker a media tanda. Si no tienes ack correcto y jobs persistentes, duplicas procesos o te tragas tareas.

Decisión práctica: si una tarea puede tardar más de ~200–500 ms, o depende de un tercero, o la puedes reintentar sin romper la UX, huele a job en cola.

Colas y workers sin magia (pero con reglas)

1) Decide qué se va a la cola

No metas todo “porque sí”. Un filtro que funciona:

Sí a la cola:

  • Enviar email, SMS, push.
  • Generar reportes o exportaciones.
  • Procesar imágenes (resize, thumbnails).
  • Consumir webhooks.
  • Reconciliación/batch (cortes, cierres, sync).

No (todavía) a la cola:

  • Validaciones necesarias para responderle al usuario.
  • Transacciones que deben ser atómicas dentro del request.
  • Lógica donde “si falla, mejor le digo al usuario en ese momento”.

Cicatriz clásica: el endpoint trae await sendEmail() y el proveedor se cae un viernes. Ahí es cuando entiendes por qué existen las colas.

2) Define el contrato del job (payload mínimo y estable)

El payload ideal no es “toda la info del pedido”, es lo mínimo para reconstruir el estado:

  • IDs (userId, orderId, invoiceId)
  • tipo de evento
  • metadatos necesarios (requestId/traceId)

Evita mandar objetos completos que cambian de forma y luego te rompen jobs viejos.

Ejemplo:

{
  "type": "SEND_INVOICE_EMAIL",
  "invoiceId": "inv_123",
  "userId": "usr_77",
  "requestId": "req_abc123"
}

3) Idempotencia: el antídoto contra doble cobro y spam

En colas, los duplicados pasan. Por:

  • retries
  • timeouts
  • workers que mueren
  • “lo mandé dos veces porque no vi el log”

Regla: un job debe poder ejecutarse 2 veces y terminar con el mismo efecto.

Técnicas que sí se usan:

  • Idempotency key guardada en DB (o Redis) por efecto: invoiceEmailSentAt, paymentCapturedAt.
  • Unique constraints para “no insertes dos veces”.
  • En integraciones externas, manda Idempotency-Key (Stripe lo soporta; en otras toca cuidarlo local).

Pseudocódigo:

if (invoice.emailSentAt) return; // ya quedó, no spamees

await emailProvider.send(...);
await db.invoices.update({ id: invoiceId, emailSentAt: new Date() });

4) Retries con backoff (sin hacer DoS a tu propio sistema)

No todos los errores merecen el mismo trato:

  • Transitorios (timeouts, 502, rate limit): reintenta.
  • Permanentes (payload inválido, “user not found”): no reintentes 20 veces.

Receta que aguanta producción:

  • 3–10 reintentos máximo
  • backoff exponencial con jitter
  • clasifica errores

Si tu proveedor trae bronca 5 minutos y tú reintentas cada 1 segundo, lo único que logras es tirarte tú solo (y de paso que te bloqueen).

5) Concurrency: cuántos workers y cuántos jobs en paralelo

Si le pones concurrency=50 porque “queremos velocidad”, luego llega el bug report:

  • la DB saturada
  • Redis lento
  • el proveedor rate-limit
  • la VM sin RAM

Empieza conservador:

  • concurrency 5–10 para I/O (emails, webhooks)
  • concurrency 1–2 para CPU pesado (PDF, imágenes) o usa otro pool

Regla útil: escala por colas separadas (email vs pdf vs sync) antes de aventarte un “worker monstruo” que hace todo.

6) DLQ (Dead Letter Queue): tu bote de basura… con evidencia

Si un job falla N veces, ¿qué pasa?

  • Si lo pierdes: nadie se entera hasta que el cliente se queja.
  • Si se reintenta infinito: tu cola se vuelve pantano.

Solución: mándalo a una DLQ (o al menos déjalo como failed) y guarda:

  • payload
  • error
  • stack trace
  • timestamp
  • intentos

Luego sí puedes reprocess con calma, corregir datos o hacer rollback manual con certeza.

Implementación práctica: Redis + BullMQ (Node.js)

Setup mínimo

Necesitas:

  • Redis (local con Docker o managed)
  • Un proceso “API” que encola
  • Un proceso “worker” que consume

Docker (local):

docker run -p 6379:6379 --name redis -d redis:7

Instala BullMQ:

npm i bullmq ioredis

1) Crear la cola y encolar desde tu API

// queue.js
import { Queue } from 'bullmq';
import IORedis from 'ioredis';

export const connection = new IORedis(process.env.REDIS_URL);

export const emailQueue = new Queue('email', { connection });
// enqueue.js (por ejemplo en tu endpoint)
import { emailQueue } from './queue.js';

export async function enqueueInvoiceEmail({ invoiceId, userId, requestId }) {
  // jobId estable ayuda a deduplicar (no es magia, pero baja el caos)
  const jobId = `invoice-email:${invoiceId}`;

  await emailQueue.add(
    'send-invoice-email',
    { invoiceId, userId, requestId },
    {
      jobId,
      attempts: 5,
      backoff: { type: 'exponential', delay: 1000 },
      removeOnComplete: 1000,
      removeOnFail: false
    }
  );
}

Decisión práctica: ese jobId ayuda cuando tu API reintenta por un timeout y termina encolando doble. No te salva de todo (hay condiciones de carrera), pero sí te evita varios tickets de soporte.

2) Worker: consumir, procesar e implementar idempotencia

// worker.js
import { Worker } from 'bullmq';
import { connection } from './queue.js';

async function sendInvoiceEmail(job) {
  const { invoiceId, userId, requestId } = job.data;

  // 1) Carga estado actual
  const invoice = await db.invoices.findById(invoiceId);
  if (!invoice) throw new Error(`Invoice not found: ${invoiceId}`);

  // 2) Idempotencia
  if (invoice.emailSentAt) {
    job.log(`Skip: already sent. requestId=${requestId}`);
    return;
  }

  // 3) Enviar (posible error transitorio)
  await emailProvider.sendInvoice({ invoiceId, userId });

  // 4) Marcar como enviado
  await db.invoices.update(invoiceId, { emailSentAt: new Date() });
}

export const worker = new Worker(
  'email',
  async (job) => {
    if (job.name === 'send-invoice-email') return sendInvoiceEmail(job);
    throw new Error(`Unknown job: ${job.name}`);
  },
  {
    connection,
    concurrency: 10
  }
);

worker.on('failed', (job, err) => {
  console.error('Job failed', { id: job?.id, name: job?.name, err: err?.message });
});

Tradeoff real (de los que te pegan en production):

  • Si marcas emailSentAt antes de mandar, evitas duplicados pero puedes perder correos si el envío falla.
  • Si marcas después, puedes duplicar si el worker truena entre send y update.

En equipos con callos, esto se resuelve con:

  • proveedor con idempotency key (si existe)
  • outbox pattern
  • o una tabla email_attempts con constraint única

3) Separa colas por tipo de carga

  • email (I/O, concurrency alto)
  • pdf (CPU, concurrency bajo)
  • webhooks (ráfagas, rate limit, retries)

Así evitas que un batch de PDFs te congele los correos y luego te caiga: “es que no le llegó el acceso al curso”.

Colas y workers: el trabajo pesado que nadie ve (hasta que truena) - visual explicativa 1
Visual de apoyo: Qué te vas a llevar

Observabilidad que sí te salva: métricas, logs y alertas

Si no lo mides, se vuelve leyenda urbana: “según esto, el worker sí corre…”.

Mínimos que necesitas:

  • Queue length (jobs en espera)
  • Processing rate (jobs/min)
  • Failures (por tipo de error)
  • Job latency (tiempo en cola + ejecución)

Alertas útiles (sin ruido):

  • “cola email > 5,000 por 10 min”
  • “fail rate > 5% en 15 min”
  • “job latency p95 > 2 min”

Logs: mete requestId o traceId en el payload. Cuando te digan “mi factura no llegó”, buscas por invoiceId y requestId y no andas adivinando.

Screenshots sugeridos

  • Dashboard de BullMQ (Bull Board) mostrando waiting/active/failed.
  • Gráfica en Grafana: tamaño de cola vs tasa de procesamiento.
  • Ejemplo de log de un job fallido con invoiceId y requestId.
  • Captura de Redis keys/metrics (si usan Redis Exporter).

Errores comunes + cómo salir del hoyo

1) “Metimos todo a una sola cola”

Síntoma: un job pesado (PDF) atrasa todo (emails, webhooks).
Arreglo: separa colas por perfil de carga y prioridad. Incluso pools de workers distintos.

2) Retries infinitos (o retries tontos)

Síntoma: la cola nunca baja y el proveedor externo ya te bloqueó.
Arreglo: attempts limit + backoff exponencial + clasificar errores permanentes.

3) Duplicados que pegan en dinero o comunicación

Síntoma: doble cobro, doble correo, doble webhook.
Arreglo: idempotencia real (flags en DB, constraints, idempotency keys). Asume que duplicados van a pasar.

4) “El worker se murió y nadie se dio cuenta”

Síntoma: la app “funciona”, pero no se ejecuta nada.
Arreglo: healthchecks del worker, alertas por queue length y por “no hay consumers”.

5) Jobs con payload gigante

Síntoma: Redis crece, se pone lento, aparecen timeouts raros.
Arreglo: manda IDs, no objetos; guarda lo demás en DB.

Colas y workers: el trabajo pesado que nadie ve (hasta que truena) - visual explicativa 2
Visual de apoyo: Por qué el backend necesita “sombras”

Checklist final (para que production no te agarre en curva)

  • Identifiqué tareas candidatas: lentas, variables o dependientes de terceros.
  • Payload mínimo: IDs + requestId/traceId.
  • Idempotencia implementada (flag/constraint/keys).
  • Retries con attempts limit + backoff exponencial.
  • DLQ o “failed jobs” retenidos con evidencia.
  • Colas separadas por tipo de carga y prioridad.
  • Concurrency ajustado (no basado en fe).
  • Métricas y alertas: tamaño de cola, fail rate, latencia.
  • Runbook básico: cómo pausar, drenar, reintentar y reprocess.

FAQ

1) ¿Cola o cron job?

Cron sirve para cosas programadas (cada X tiempo). Cola sirve para trabajo disparado por eventos (checkout, webhook, registro). Muchas veces conviven: cron encola jobs.

2) ¿Redis (BullMQ) o RabbitMQ/Kafka/SQS?

  • Redis/BullMQ: rápido para arrancar, gran DX, ideal para jobs. Ojo con memoria y persistencia.
  • RabbitMQ: routing más formal, acknowledgements sólidos.
  • Kafka: streams/eventos a gran escala, otra liga.
  • SQS: managed, menos dolor operativo, buen fit en AWS.

3) ¿Cómo evito perder jobs si se reinicia el worker?

Asegura que la cola sea persistente (config/infra), que el worker haga ack correcto (la librería lo maneja), y que tu operación sea idempotente.

4) ¿Qué hago si mi cola ya tiene 1 millón de jobs atorados?

Primero: cero pánico y no subas concurrency a lo loco. Encuentra el cuello: DB, proveedor, CPU. Luego escala horizontalmente workers por cola y considera pausar o filtrar duplicados. Si hay bug en payload, manda a DLQ y reprocess ya con el fix.

5) ¿Qué significa “exactly once”? ¿Existe de verdad?

En la práctica casi siempre vives con at-least-once (puede duplicar). “Exactly once” sale caro y se vuelve complejo; mejor diseña idempotencia para sobrevivir duplicados.

Siguiente episodio

La cola ya jala… pero si sigue siendo caja negra, te va a morder.
Lo que sigue: observabilidad para el backend underworld — trazas, métricas y alertas que sí te despiertan por lo correcto.