wandres.dev
QUEUES · mensajería asíncrona

Casos reales: correos, miniaturas, webhooks y picos

La teoría de las colas se vuelve intuición cuando reconoces sus formas en problemas concretos. Recorremos cinco: el correo transaccional que no debe retrasar un alta, la miniatura que se genera a partir de una clave de R2 y no del archivo, el webhook saliente hacia un mundo poco fiable donde el backoff y la firma son obligatorios, el pico de carga que la cola convierte en profundidad en vez de en caída, y el pipeline de varias etapas donde cada cola ajusta su lote y su concurrencia por separado. Cerramos con la disciplina inversa: los tres casos en los que meter una cola es la decisión equivocada.

⏱ 17 min

Las colas se entienden de verdad cuando dejas de verlas como una pieza de infraestructura y empiezas a reconocer su forma en los problemas. Y esa forma se repite con una regularidad casi aburrida: hay algo que el usuario espera y algo que no; hay un servicio propio que aguanta miles de peticiones por segundo y uno ajeno que aguanta diez; hay un caudal que llega a ráfagas y un procesamiento que solo sabe ir a ritmo constante. Cada vez que aparece esa asimetría, una cola es la respuesta. Vamos a recorrer los cinco casos donde más se repite, y a cerrar con los tres donde meter una cola es precisamente el error.

🎯 Al terminar esta lección sabrás
  • Reconocer la asimetría —rápido contra lento, fiable contra frágil— que delata la necesidad de una cola.
  • Aplicar el patrón de referencia en lugar de carga en trabajos con archivos, usando R2 como almacén.
  • Usar la concurrencia y el backoff para proteger a terceros frágiles en webhooks salientes.
  • Encadenar colas por etapas y saber cuándo el problema pide un Workflow, un cron o una llamada directa.

Lo que el usuario no espera: correos y miniaturas

El correo transaccional es el caso fundacional. Un alta, una compra o un cambio de contraseña deben confirmarse al instante, pero el correo que los acompaña depende de un proveedor externo con su propia latencia, sus límites por minuto y sus caídas. Encolar un mensaje con el identificador del usuario y el nombre de la plantilla saca esa dependencia del camino de la petición, y de paso te regala reintentos gratis: si el proveedor devuelve un 503, el mensaje vuelve a la cola y el correo sale tres minutos tarde en vez de no salir nunca.

La generación de miniaturas ilustra la otra mitad del patrón. El usuario sube una imagen, tu Worker la guarda en R2 y responde ya; el mensaje que encola no lleva la imagen, lleva su clave.

async fetch(request: Request, env: Env): Promise<Response> {
  const clave = `originales/${crypto.randomUUID()}.jpg`;
  await env.MEDIA.put(clave, request.body);
  await env.MINIATURAS.send({ clave, tamanos: [256, 1024] });
  return Response.json({ clave }, { status: 202 });
}

Esa distinción entre encolar la referencia y encolar el archivo no es un capricho de tamaño. El mensaje se mantiene diminuto, el consumidor lee siempre la versión vigente del objeto y, si mañana quieres tres tamaños más, cambias el consumidor sin tocar ni un mensaje en vuelo. El trabajo pesado —descargar, redimensionar, volver a escribir— ocurre en su propia invocación, con su propio presupuesto de CPU, lejos de la respuesta que el usuario está esperando.

💡
Encola la clave, nunca los bytes

La regla se generaliza a cualquier trabajo sobre archivos: guarda primero en R2 y encola la clave. Además de respetar el límite de tamaño del mensaje, desacoplas el ciclo de vida del dato del ciclo de vida del trabajo. Si el consumidor falla y reintenta tres días después, el objeto sigue ahí; si hubieras metido los bytes en el mensaje, tendrías una copia congelada viajando por el sistema y dos fuentes de verdad para el mismo archivo.

Webhooks salientes: hablar con un mundo frágil

Notificar a sistemas de terceros es, con diferencia, el caso donde una cola pasa de conveniente a indispensable. Cuando emites webhooks, cada destinatario es un servidor que no controlas: uno responde en veinte milisegundos, otro tarda ocho segundos, un tercero lleva dos días caído y un cuarto tiene un límite de diez peticiones por minuto que castiga con bloqueos. Intentar eso de forma síncrona dentro de una petición es garantizar que el eslabón más lento del mundo dicte tu latencia.

El patrón es siempre el mismo: el evento se publica una vez, se abre en abanico hacia un mensaje por suscriptor con sendBatch, y el consumidor entrega cada uno con su propia suerte. Ahí se juntan casi todas las piezas del nivel.

async queue(batch: MessageBatch<Aviso>, env: Env): Promise<void> {
  for (const m of batch.messages) {
    try {
      const cuerpo = JSON.stringify(m.body.evento);
      const respuesta = await fetch(m.body.url, {
        method: "POST",
        headers: { "x-firma": await firmar(cuerpo, env.SECRETO) },
        body: cuerpo,
        signal: AbortSignal.timeout(5000),
      });
      if (respuesta.status >= 500) throw new Error(`origen ${respuesta.status}`);
      m.ack(); // incluso un 4xx se confirma: no mejorara reintentando
    } catch {
      m.retry({ delaySeconds: Math.min(2 ** m.attempts * 15, 3600) });
    }
  }
}

Tres detalles hacen que ese código sea correcto y no solo funcional. El tiempo de espera acotado impide que un destinatario zombi se coma la invocación entera. La distinción entre un 5xx —transitorio, se reintenta— y un 4xx —permanente, se confirma y se registra— evita quemar cinco intentos contra una URL que ya no existe. Y la firma en la cabecera permite al receptor verificar que el aviso viene de ti, algo que la asincronía hace más necesario, no menos, porque el mensaje llega desprovisto de cualquier contexto de sesión.

📧

Correo transaccional

Referencia al usuario y a la plantilla. Concurrencia limitada a la cuota del proveedor y reintentos ante cualquier 5xx.

🖼️

Miniaturas y medios

La clave de R2 dentro del mensaje. Lotes pequeños porque cada elemento consume CPU y memoria de verdad.

🪝

Webhooks salientes

Un mensaje por suscriptor, tiempo de espera acotado, backoff exponencial, firma y cola de fallidos por destinatario roto.

📊

Ingesta y analítica

Lotes grandes y espera larga: cien eventos que se escriben en D1 o R2 con una sola operación en vez de cien.

La cola como amortiguador de picos

Hay un caso donde la cola no desacopla por conveniencia sino por supervivencia. Tu Worker escala a decenas de miles de peticiones por segundo sin despeinarse; tu base de datos, tu Postgres tras Hyperdrive o esa API interna heredada, no. Sin nada en medio, un pico de tráfico se transmite íntegro aguas abajo y tumba el eslabón más débil, que arrastra al resto.

La cola convierte ese pico en profundidad. Diez mil eventos que llegan en un segundo se encolan en un segundo, y el consumidor los drena al ritmo que su destino tolera. Nada se pierde, nada se cae; solo se retrasa, que es infinitamente preferible. El ajuste que hace posible esa regulación es max_concurrency, combinado con un lote grande que agrupe las escrituras.

{
  "queues": {
    "consumers": [
      {
        "queue": "ingesta",
        "max_batch_size": 100,
        "max_batch_timeout": 30,
        "max_concurrency": 3
      }
    ]
  }
}

Con esos números el destino recibe como mucho tres escrituras simultáneas de cien filas agrupadas, llegue el tráfico que llegue. La cola absorbe la varianza y el sistema pasa de frágil a elástico sin tocar una sola línea del servicio que había que proteger.

Lo que hay que vigilar en este montaje no es el pico, que es transitorio por definición, sino la pendiente. Si el caudal de entrada supera de forma sostenida al de salida, la profundidad crece sin techo y acabará chocando con el periodo de retención. Una cola amortigua ráfagas; no arregla un desequilibrio estructural entre lo que produces y lo que puedes procesar. Cuando la gráfica de retraso no vuelve a cero entre picos, la respuesta no es subir la concurrencia a ciegas —eso solo traslada el problema al eslabón protegido— sino abaratar el trabajo por mensaje, agrupar más o admitir que hace falta más capacidad al otro lado.

flowchart LR
T[Pico de trafico] --> W[Workers que escalan sin limite]
W -->|sendBatch| Q[Cola cuya profundidad crece]
Q -->|caudal constante| C[Consumidor con concurrencia fijada]
C --> DB[Base de datos protegida]
style Q fill:#89b4fa,color:#11111b
style DB fill:#a6e3a1,color:#11111b
style T fill:#f38ba8,color:#11111b

Pipelines por etapas, y cuándo no usar una cola

Cuando el trabajo tiene fases con costes distintos, una sola cola las obliga a compartir ajustes que no les convienen a ambas. La solución es encadenar: el consumidor de una cola es el productor de la siguiente. Una etapa de descarga puede permitirse lotes grandes y mucha concurrencia porque solo espera red; una etapa de transformación pide lotes pequeños porque consume CPU; una etapa de escritura vuelve a querer lotes grandes para agrupar. Separarlas en tres colas deja que cada una se calibre por su cuenta y que un atasco en la tercera no bloquee a la primera.

Etapa Lote Concurrencia Motivo
Descarga grande alta Solo espera red, apenas gasta CPU
Transformación pequeño media Consume CPU y memoria por elemento
Escritura grande baja Agrupa filas y protege al destino

Encadenar tiene además una virtud que solo se aprecia en producción: cada frontera es un punto de inspección. Si algo va mal, la profundidad de las colas te dice exactamente en qué etapa se atasca el caudal, y puedes reprocesar desde ahí sin repetir las anteriores. Un pipeline monolítico dentro de un solo consumidor no te da ese diagnóstico: solo te dice que falló, y te obliga a rehacerlo entero. La misma lógica desaconseja pasar el resultado íntegro de una etapa a la siguiente dentro del mensaje; escribe el resultado intermedio en R2 o en D1 y encola su clave, y así cada etapa arranca leyendo de un sitio estable en vez de heredar una carga que quizá ya no represente la verdad.

Y ahora la disciplina inversa, que es igual de importante. Una cola es la herramienta equivocada cuando necesitas la respuesta del trabajo para contestar al usuario: ahí querías una llamada directa, y un service binding con RPC te la da sin salir de la red de Cloudflare. Es la herramienta equivocada cuando el trabajo es un proceso largo de varios pasos con estado y esperas de días: eso es un Workflow, y forzarlo en un consumidor produce ese switch monstruoso sobre un campo de estado que delata a un Workflow reimplementado a mano. Y es la herramienta equivocada cuando el disparador es un reloj y no un suceso: eso es un cron, aunque después ese cron reparta trabajo encolando mensajes.

ℹ️
La composición es lo normal, no la excepción

Los casos reales rara vez usan una sola pieza. Un cron nocturno abre en abanico hacia una cola con sendBatch; el consumidor de esa cola lanza un Workflow por cada elemento que exige un viaje largo y fiable; ese Workflow escribe en R2 y publica un hecho en otra cola. Cada capa aporta lo suyo —el tiempo, la carga, el proceso— y ninguna intenta hacer el trabajo de otra. Reconocer esa estratificación es lo que convierte un catálogo de productos en una arquitectura.

Una cola no acelera tu sistema: reasigna quién paga la espera

Conviene desactivar de una vez el eslogan con el que casi todo el mundo llega a la mensajería asíncrona, ese de que las colas hacen las cosas más rápidas. No hacen nada más rápido. El correo tarda exactamente lo mismo en salir, la miniatura tarda lo mismo en generarse, el webhook tarda lo mismo en llegar; de hecho, sumando el encolado, la espera del lote y la entrega, el trabajo total tarda algo más que si lo hubieras hecho en línea. Lo que cambia, y es lo único que cambia, es quién espera. Antes esperaba el usuario, con el navegador girando, atado a la suma de todas las latencias del sistema y a la fiabilidad de su eslabón más pobre. Ahora espera un proceso de fondo al que no le importa esperar, que puede reintentar sin que nadie se entere y que fracasa sin arruinarle el día a nadie. Una cola es, en su esencia más pura, una reasignación del coste de la espera desde donde duele hacia donde no duele. De esa lectura se derivan las decisiones de diseño que de otro modo parecen arbitrarias. Explica por qué la pregunta correcta nunca es “qué puedo mover a una cola”, que invita a mover todo, sino “qué le he prometido al usuario en el instante en que pulsó el botón”: lo prometido debe completarse antes de responder, y todo lo demás pertenece a la cola. Explica por qué encolar la clave de R2 en vez del archivo es un principio y no una optimización: el mensaje describe dónde está el trabajo, no lo transporta, porque transportarlo sería volver a acoplar el dato al instante en que se produjo. Explica por qué limitar la concurrencia, que suena a renunciar a rendimiento, es lo que de verdad hace escalable el conjunto: el sistema entero va tan rápido como su parte más lenta, y una cola es el único sitio donde puedes admitir ese hecho sin propagarlo a todos los demás. Y explica, finalmente, por qué la consistencia final no es una degradación que se tolera sino una elección que se hace: aceptas que el correo llegue tres segundos tarde a cambio de que el alta no falle jamás por culpa del correo. Quien interioriza esto deja de preguntarse si debe usar colas y empieza a preguntarse dónde están, en su dominio, las fronteras entre lo que hay que prometer ya y lo que basta con prometer eventualmente. Esas fronteras existen antes que la tecnología; Queues solo es la forma de hacerlas explícitas y duraderas.

⚔️ Encuentra las asimetrías de tu sistema
  1. Enumera tres puntos de tu aplicación donde algo rápido y fiable dependa hoy de algo lento o frágil, y decide cuál merece una cola.
  2. Implementa el flujo de miniaturas completo: subida a R2, mensaje con la clave, consumidor que genera dos tamaños y escribe el resultado.
  3. Escribe el consumidor de webhooks con tiempo de espera acotado, distinción entre 4xx y 5xx, backoff exponencial y firma.
  4. Divide un trabajo tuyo de tres fases en tres colas encadenadas y justifica el lote y la concurrencia de cada una.
  5. Señala una tarea que hoy tengas en una cola y que en realidad pida un Workflow, un cron o una llamada directa, y argumenta el cambio.