Qué es Cloudflare Queues: desacoplar el trabajo pesado
Una cola no es una lista de tareas pendientes: es un búfer duradero que rompe el acoplamiento temporal entre quien genera trabajo y quien lo ejecuta. Cloudflare Queues aporta entrega garantizada al menos una vez, retención de días, agrupación en lotes y cero cargos de egress porque nunca sale de la red de Cloudflare. Vemos por qué el camino de la petición es el peor sitio para trabajar, qué promete y qué no promete la entrega garantizada, cómo se declaran las tres piezas —cola, productor y consumidor— y por qué la ausencia de egress cambia el cálculo de una arquitectura entera.
Hay una asimetría brutal en toda aplicación web: el usuario espera una respuesta en milisegundos, pero el trabajo que su acción desencadena puede tardar segundos o minutos. Enviar el correo de bienvenida, generar la miniatura, avisar a tres servicios externos, recalcular un informe. El principiante mete todo eso dentro del manejador fetch y lo paga con latencia, con timeouts y con fallos que se llevan por delante la petición entera. Cloudflare Queues existe para romper esa atadura: pone un búfer duradero entre el instante en que el trabajo nace y el instante en que se ejecuta, de modo que el usuario recibe su respuesta al momento y el trabajo pesado ocurre después, con reintentos, sin prisa y sin público.
- Definir una cola como un búfer duradero que desacopla a quien produce trabajo de quien lo ejecuta.
- Precisar qué garantiza la entrega al menos una vez y qué obligación deja en tu código.
- Situar las tres piezas del sistema: la cola declarada, el productor que encola y el consumidor que procesa.
- Valorar qué significa que no haya cargos de egress y cómo eso reordena decisiones de arquitectura.
El camino de la petición es sagrado
Piensa en qué ocurre cuando alguien pulsa “Crear cuenta”. Lo esencial —validar los datos, escribir la fila, devolver un identificador— cabe en unas decenas de milisegundos. Lo accesorio —mandar el correo de verificación, dar de alta al usuario en el CRM, avisar por webhook al equipo comercial, generar su avatar por defecto— puede tardar segundos y depende de servicios de terceros que a veces fallan o van lentos.
La frontera entre ambos grupos no es técnica sino contractual: lo esencial es aquello que el usuario tiene derecho a considerar terminado cuando ve la respuesta, y lo accesorio es todo lo demás. Esa distinción la dicta el dominio, no la arquitectura, y trazarla mal es el origen de la mayoría de los diseños asíncronos que salen mal.
Si encadenas todo dentro del mismo manejador fetch, atas la suerte del usuario a la del proveedor de correo más lento del mundo. Y el acoplamiento es doble: temporal, porque el usuario no verá su respuesta hasta que la última llamada externa termine; y de fiabilidad, porque un error en el paso menos importante hace fracasar la operación más importante. Un alta perfectamente válida se pierde porque un webhook devolvió un 503.
La respuesta intuitiva es ctx.waitUntil, y es un buen primer paso: la respuesta sale ya y el trabajo continúa en segundo plano. Pero waitUntil vive dentro del mismo isolate, comparte su presupuesto de CPU y su límite de subpeticiones, y si el proceso muere el trabajo se evapora sin dejar rastro. No hay reintentos, no hay persistencia, no hay visibilidad. Sirve para el registro de una métrica; no para algo que debe ocurrir.
Una cola es la versión seria de esa misma idea. El productor escribe un mensaje y termina; el mensaje queda persistido en la infraestructura de Cloudflare, fuera de tu isolate; y más tarde —milisegundos o minutos después— un consumidor lo recoge y hace el trabajo, con sus propios límites, sus propios reintentos y su propia observabilidad.
| Enfoque | Sobrevive al fallo | Reintentos | Presupuesto de CPU | Para qué sirve |
|---|---|---|---|---|
Trabajo dentro de fetch |
No | No | El de la petición | Solo lo esencial que el usuario debe ver confirmado |
ctx.waitUntil |
No | No | El de la petición | Métricas, registros, efectos que puedes permitirte perder |
| Cola | Sí | Sí | Uno propio por lote | Todo lo que debe ocurrir aunque nadie esté mirando |
La tercera columna es la que más se subestima. Un trabajo dentro de waitUntil compite por el mismo tiempo de CPU que la petición que lo lanzó, así que redimensionar una imagen ahí no solo es frágil: puede agotar el presupuesto y llevarse por delante la respuesta. En una cola, cada lote es una invocación nueva con su propia cuenta desde cero.
Respuesta inmediata
El productor solo escribe el mensaje. La petición del usuario se cierra en milisegundos sin esperar al trabajo pesado.
El trabajo no se pierde
El mensaje se persiste antes de confirmarse. Si el consumidor falla, se reintenta; el fallo del trabajo no arrastra a la petición.
Aplana los picos
Diez mil altas de golpe encolan diez mil mensajes que el consumidor drena a su ritmo, sin desbordar a nadie aguas abajo.
Qué garantiza la entrega, y qué no
“Entrega garantizada” es una frase que conviene desmenuzar, porque promete menos de lo que suena y a la vez es exactamente lo que necesitas. Queues ofrece entrega al menos una vez: una vez que la llamada al productor se resuelve sin error, Cloudflare se compromete a entregar ese mensaje a un consumidor, reintentando si hace falta, hasta que sea confirmado o hasta agotar la política de reintentos.
Lo que no promete es entregarlo exactamente una vez. Un mensaje puede llegar dos veces: el consumidor lo procesó bien pero murió antes de confirmarlo, la red se cortó en el momento equivocado, un reintento se solapó con el original. Eso no es un defecto de Queues, es una consecuencia demostrable de los sistemas distribuidos: no existe un canal que garantice a la vez que nada se pierde y que nada se duplica. Al elegir, Queues prefiere duplicar antes que perder, y traslada la otra mitad del problema a tu consumidor, que debe ser idempotente.
Si tu consumidor cobra una tarjeta, envía un correo o incrementa un contador, procesarlo dos veces tiene consecuencias visibles. La solución habitual es dar a cada mensaje una clave estable —el identificador del pedido, no un valor aleatorio generado al encolar— y registrar en KV, D1 o un Durable Object que esa clave ya se procesó, comprobándolo antes de actuar. Diseñar el consumidor para que repetirlo sea inofensivo es el precio de la entrega garantizada, y es un precio barato comparado con perder mensajes.
Junto a esa garantía vienen otras propiedades menos filosóficas pero igual de prácticas. Los mensajes se retienen varios días si nadie los consume, de modo que un consumidor caído no significa trabajo perdido. Se entregan en lotes, lo que amortiza el arranque y las escrituras aguas abajo. El consumidor escala solo según la profundidad de la cola. Y cada mensaje puede reintentarse de forma independiente sin arrastrar a sus compañeros de lote.
Esa retención de varios días es más importante de lo que parece, porque redefine qué significa una caída. Si tu consumidor está roto un martes por la tarde, el trabajo no se pierde: se acumula. Arreglas el defecto, despliegas, y la cola drena sola lo pendiente. Sin ese búfer, cada minuto de indisponibilidad sería un agujero permanente en tus datos, y la única mitigación posible sería pedirle al usuario que lo intente otra vez. Con él, una incidencia de una hora se convierte en un retraso de una hora.
Otra propiedad que conviene fijar pronto es que un mensaje se entrega a un consumidor, no a todos los interesados. Una cola no es un canal de difusión: si dos sistemas distintos deben reaccionar al mismo hecho, no compiten por la misma cola, sino que el productor encola en dos colas o un primer consumidor abre en abanico hacia las siguientes. Confundir ambos modelos produce el error clásico de dos consumidores conectados a la misma cola preguntándose por qué cada uno solo ve la mitad de los mensajes.
Tres piezas y una declaración
Antes de mirar el código conviene fijar el vocabulario, porque en mensajería casi todos los términos suenan parecidos. Un mensaje es una unidad de trabajo con su cuerpo y su identificador. Un lote es el grupo de mensajes que la plataforma entrega en una sola invocación. La profundidad es cuántos mensajes esperan sin consumir. Y el retraso es cuánto lleva esperando el más viejo de ellos, que es la métrica que de verdad describe la salud del sistema.
El sistema entero tiene tres piezas y ninguna sorpresa. La cola es el recurso con nombre que vive en Cloudflare. El productor es un Worker con un binding hacia esa cola, que escribe mensajes. El consumidor es un Worker con un manejador queue que recibe lotes de esa misma cola. Productor y consumidor pueden ser el mismo Worker o dos distintos; ninguno conoce al otro, solo conocen la cola.
{
"queues": {
"producers": [
{ "queue": "tareas-alta", "binding": "TAREAS" }
],
"consumers": [
{ "queue": "tareas-alta", "max_batch_size": 10, "max_batch_timeout": 5 }
]
}
}
Esa declaración es todo el cableado. El bloque producers te da env.TAREAS con un método send; el bloque consumers le dice a Cloudflare que invoque el manejador queue de este Worker con lotes de hasta diez mensajes o cada cinco segundos, lo que ocurra antes. Que ambos bloques convivan en el mismo manifiesto es lo que permite empezar con un Worker que se manda trabajo a sí mismo.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const usuario = await crearUsuario(request, env);
// el trabajo pesado sale del camino de la peticion
await env.TAREAS.send({ tipo: "bienvenida", usuarioId: usuario.id });
return Response.json({ id: usuario.id }, { status: 202 });
},
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const mensaje of batch.messages) {
await procesar(mensaje.body, env);
mensaje.ack();
}
},
} satisfies ExportedHandler<Env>;
flowchart LR U[Usuario] -->|POST alta| P[Worker productor] P -->|responde 202 al instante| U P -->|send| Q[Cola duradera] Q -->|lote con reintentos| C[Worker consumidor] C --> MAIL[Correo] C --> CRM[CRM externo] style Q fill:#89b4fa,color:#11111b style P fill:#a6e3a1,color:#11111b style C fill:#cba6f7,color:#11111b
Queda una propiedad económica que merece un párrafo propio: no hay cargos de egress. Los mensajes nacen en un Worker, se persisten en la red de Cloudflare y se entregan a otro Worker, sin cruzar nunca la frontera de facturación que otros proveedores cobran por byte que sale de su nube. Pagas por operación —encolar, entregar, reintentar— y nada por el volumen que se mueve. Es la misma filosofía de R2 aplicada a la mensajería, y tiene un efecto silencioso sobre el diseño: deja de ser caro mover datos por la tubería, así que puedes encolar cargas ricas y multiplicar las etapas sin que la factura te castigue por hacer bien las cosas.
El precio: orden, duplicados y consistencia final
Ninguna abstracción es gratis, y conviene conocer la factura antes de firmar. La primera partida ya la viste: los duplicados son posibles y tu consumidor debe ser idempotente. La segunda es que el orden no está garantizado. Los mensajes tienden a entregarse en el orden en que llegaron, pero un reintento rompe esa tendencia sin avisar: si el mensaje número tres falla y vuelve a la cola, se procesará después del cuatro y del cinco.
Eso descarta de raíz un diseño muy tentador, el de encolar una secuencia de cambios sobre la misma entidad —crear, actualizar, borrar— confiando en que llegarán en orden. Cuando el orden importa, tienes dos caminos honestos: diseñar mensajes que sean conmutativos, de forma que aplicarlos en cualquier orden lleve al mismo estado final, o delegar la serialización en la pieza que sí la garantiza, un Durable Object por entidad que recibe los mensajes y decide él la secuencia.
La misma cautela vale para el reloj. Un mensaje trae la marca de tiempo de cuándo se encoló, no de cuándo se procesa, y entre ambos instantes puede haber segundos o días. Un consumidor que calcula algo con la hora actual —“si han pasado más de diez minutos, cancela”— puede tomar decisiones absurdas sobre un mensaje reintentado. Si el tiempo importa para tu lógica, léelo del mensaje y no del reloj del consumidor.
La tercera partida es conceptual y es la más importante: al meter una cola, cambias consistencia inmediata por consistencia final. Justo después de responder al usuario, el sistema está en un estado intermedio legítimo pero incompleto: la cuenta existe y el correo aún no salió. Tu interfaz debe contar esa verdad —“te hemos enviado un correo, puede tardar un minuto”— en lugar de fingir que todo terminó. Un sistema asíncrono que se presenta como síncrono es un sistema que va a mentir en algún momento.
La métrica que hay que vigilar no es la latencia del consumidor sino el retraso acumulado de la cola. Si crece de forma sostenida, tu consumidor procesa más despacio de lo que tu productor encola, y eso solo tiene dos desenlaces: o ajustas el lote y la concurrencia, o el retraso alcanza el periodo de retención y empiezas a perder mensajes. Un pico puntual es sano; una pendiente que no baja es un incidente en marcha.
Es tentador entender una cola como un simple aplazamiento —lo mismo de siempre, pero más tarde— y esa lectura, aunque cómoda, se queda en la superficie. Lo que una cola introduce de verdad no es retraso, es una frontera. Antes de ella, tu sistema era una cadena de dependencias rígidas donde el eslabón más lento y menos fiable dictaba la experiencia del eslabón más rápido y crítico: el alta de un usuario valía lo que valiese el proveedor de correo esa mañana. Después de ella, hay dos sistemas separados por un contrato duradero, y cada uno falla, escala, se despliega y se razona por su cuenta. El productor solo promete una cosa: que el mensaje quedó guardado. El consumidor solo promete otra: que ese mensaje acabará procesado o acabará en la cola de fallidos. Ninguno necesita saber cuándo, dónde ni con qué éxito trabaja el otro, y esa ignorancia mutua es precisamente la propiedad valiosa. De ahí se sigue lo que hace tan poderoso este cambio: el búfer absorbe la varianza. Los picos de tráfico dejan de ser una amenaza y se vuelven profundidad de cola; una caída de un servicio externo deja de ser un incidente para el usuario y se vuelve un retraso invisible que se recupera solo; una tarea que tarda dos minutos deja de ser un timeout y se vuelve un consumidor tranquilo. Estás cambiando consistencia inmediata por consistencia final, y ese es un intercambio deliberado, no un descuido: aceptas que el correo tarde tres segundos a cambio de que el alta no falle jamás por culpa del correo. Interiorizar esto es lo que separa a quien usa una cola porque lo vio en un tutorial de quien decide qué parte de su dominio tolera la espera. La pregunta de diseño no es “qué puedo mover a una cola para ir más rápido”, sino “qué prometo de verdad al usuario en el instante en que pulsa el botón, y qué puedo prometerle solo eventualmente”. Todo lo que caiga del segundo lado pertenece a la cola, y todo lo que caiga del primero jamás debe entrar en ella.
- Toma una operación real de tu aplicación y separa por escrito lo esencial —lo que el usuario debe ver confirmado ya— de lo accesorio que puede ocurrir después.
- Declara una cola en
wrangler.jsonccon su bloque de productor y de consumidor, y despliega un Worker que se envíe un mensaje a sí mismo. - Explica con tus palabras por qué
ctx.waitUntilno basta cuando el trabajo debe ocurrir sí o sí, nombrando las tres cosas que le faltan. - Diseña la clave de idempotencia de ese trabajo: qué identificador usarías y dónde registrarías que ya se hizo.
- Argumenta qué cambia en tu arquitectura al saber que mover datos por la cola no genera cargos de egress.