wandres.dev
NIVEL DIOS: SÍNTESIS · la plataforma completa

Los límites duros y cómo convivir con ellos

Toda plataforma es un conjunto de restricciones disfrazado de conjunto de capacidades. En Cloudflare las restricciones son cuatro y conviene conocerlas de memoria: el tiempo de CPU por invocación, los 128 MB de memoria por isolate, el tamaño del bundle y la consistencia eventual de lo replicado. Vemos por qué cada una existe y qué protege, los patrones que permiten convivir con ellas sin pelearse, las tres salidas legítimas cuando la carga sencillamente no cabe —Containers, Workflows y base externa— y por qué chocar contra un límite es casi siempre un diagnóstico correcto sobre el diseño.

⏱ 25 min

Una plataforma no se conoce por lo que promete sino por dónde dice que no. Los límites de Workers no son tacañería ni una versión recortada esperando a mejorar: son la contrapartida exacta de las propiedades que hacen que el modelo funcione. Sin un techo de CPU no habría multitenencia densa; sin un límite de memoria no podrían convivir cientos de isolates en la misma máquina; sin consistencia eventual no habría replicación global. Esta lección los toma en serio, muestra cómo diseñar dentro de ellos y, cuando no caben, cuáles son las tres salidas legítimas. La conclusión incómoda es que la mayoría de las veces que chocas con un límite, el límite tiene razón.

🎯 Al terminar esta lección sabrás
  • Enunciar con precisión los cuatro límites duros y qué propiedad protege cada uno.
  • Aplicar los patrones que permiten diseñar dentro de ellos en vez de contra ellos.
  • Elegir con criterio entre las tres salidas: Containers, Workflows y base de datos externa.
  • Leer el choque contra un límite como diagnóstico de diseño y no como obstáculo administrativo.

Los cuatro límites y qué protege cada uno

El tiempo de CPU es el más malentendido porque quien viene de servidores lo confunde con el tiempo de reloj. No lo es: la espera de red no cuenta. Un Worker que tarda ocho segundos en responder porque una API externa fue lenta puede haber consumido tres milisegundos de procesador. El plan Free da 10 milisegundos de CPU por invocación; el Paid da 30 segundos por defecto y admite hasta cinco minutos configurando cpu_ms. Ese techo protege dos cosas a la vez: la densidad de la plataforma, porque un isolate que monopoliza el núcleo bloquea a sus vecinos, y tu propia factura, porque un bucle accidental sin techo es una incidencia económica.

// El techo se declara: subirlo es una decision consciente, no un ajuste automatico.
{
  "limits": { "cpu_ms": 300000 },
  "placement": { "mode": "smart" }
}

Conviene notar que el valor por defecto no está puesto para molestarte sino para protegerte: un bucle accidental sin techo no produce un error visible, produce una factura. Bajarlo por debajo de treinta segundos en un Worker del que desconfías es una técnica legítima y poco usada, porque convierte un fallo económico silencioso en un error 1102 que aparece en la analítica marcado como exceededCpu y que alguien puede investigar el mismo día.

La memoria son 128 MB, y el detalle que cambia el diseño es que son por isolate, no por petición. Tu Worker atiende varias peticiones concurrentes dentro del mismo isolate y todas comparten ese presupuesto. Eso convierte tu huella de memoria en un bien común: mantener un objeto grande en una variable no es un lujo privado, es capacidad que le quitas a las peticiones que coinciden contigo. De ahí que el estilo idiomático sea el streaming, y que la métrica correcta no sea cuánto cabe sino cuánto reside a la vez.

Junto a la memoria conviene tener presente su límite hermano, que es el de subrequests: cuántas llamadas salientes admite una invocación. En Free son 50 externas más mil hacia servicios internos; en Paid el techo por defecto es de diez mil y se puede elevar hasta millones con limits.subrequests para cargas largas. Pero el número que de verdad moldea el código no es ese, sino que solo puede haber seis conexiones salientes abiertas a la vez. Puedes hacer miles de llamadas a lo largo de una petición, no seis mil en paralelo, y eso obliga a pensar en oleadas y en lotes en vez de en abanicos ingenuos.

El tamaño del bundle son 3 MB comprimidos en Free y 10 MB en Paid, y es el límite que más gente descubre en el peor momento: el despliegue. Existe porque tu código se distribuye a cientos de ubicaciones y debe estar listo para ejecutarse sin arranque en frío; un artefacto enorme no se puede tener caliente en todas partes. Casi siempre se rebasa por una dependencia que arrastra medio ecosistema, y casi siempre se arregla mirando el análisis del bundle en lugar de pedir una excepción.

Ese límite tiene una virtud lateral que compensa la molestia: es el único mecanismo de la plataforma que te obliga a mirar qué has instalado. Una dependencia que añade dos megabytes para formatear fechas no aparece en ninguna métrica de producción, no rompe nada y sobrevive años en un proyecto; aquí impide desplegar el mismo día que entra. La disciplina de revisar el árbol de dependencias, que en otros entornos es un acto de virtud opcional, aquí es una condición para trabajar.

La consistencia eventual no es un número sino una semántica, y es el límite más profundo de los cuatro porque no se puede subir con configuración. Es el precio directo de la replicación: KV te sirve una lectura desde el punto de presencia más cercano porque hay una copia allí, y esa copia puede ir unos segundos por detrás de la escritura. Lo mismo vale para las réplicas de lectura de D1. No es un defecto pendiente de corregir: es el teorema de siempre, aplicado a una red de trescientas ubicaciones.

Lo peligroso de este cuarto límite es que, a diferencia de los otros tres, no avisa. La CPU agotada devuelve un error, la memoria desbordada mata el isolate y un bundle demasiado grande impide el despliegue; una lectura rancia devuelve un valor perfectamente válido que simplemente es el anterior. El sistema no se queja, los paneles están verdes y el fallo aparece como un informe de un usuario que jura haber guardado algo que no ve. Por eso el único momento en que se puede tratar bien es al elegir dónde vive cada hecho, y por eso esta lección insiste tanto en esa decisión.

Límite Valor Qué protege Cómo se sube
CPU por invocación 10 ms Free, 30 s Paid Densidad y factura cpu_ms hasta 5 min
Memoria 128 MB por isolate Convivencia entre peticiones No se sube
Bundle 3 MB Free, 10 MB Paid Despliegue global sin cold start Plan y adelgazar dependencias
Consistencia Eventual en lo replicado Lectura local en todo el mundo No se sube, se elige otra pieza
💡
Tres se suben con dinero, uno no

Los tres primeros límites son cuantitativos: cambian con el plan o con una línea de configuración, y por eso son los que menos deberían preocuparte. El cuarto es cualitativo y ninguna cantidad de dinero lo mueve, porque no es una cuota sino una consecuencia de estar en muchos sitios a la vez. Si tu problema es de CPU, memoria o bundle, tienes un problema de ingeniería con solución conocida. Si tu problema es de consistencia, tienes un problema de modelo de datos, y la solución nunca es esperar a que la plataforma mejore: es mover ese hecho a una pieza con otra semántica.

Diseñar dentro en lugar de pelearse

El patrón que resuelve la memoria y buena parte de la CPU es el mismo: ser tubería y no depósito. Un Worker que descarga un objeto entero de R2, lo transforma en memoria y lo devuelve consume el tamaño del objeto; el mismo Worker encadenando flujos consume una fracción constante y responde antes, porque los primeros bytes salen mientras los últimos aún se leen.

// Deposito: reserva el objeto entero en los 128 MB compartidos del isolate.
const objeto = await env.MEDIA.get(clave);
const bytes = await objeto.arrayBuffer();
return new Response(transformar(bytes));

// Tuberia: los bytes atraviesan el Worker sin habitarlo.
const stream = (await env.MEDIA.get(clave)).body;
return new Response(stream.pipeThrough(new TransformStream(transformador)), {
  headers: { "content-type": "text/html; charset=utf-8" },
});

Para la CPU, el patrón es mover el trabajo fuera del camino crítico. Casi nada de lo que consume procesador en una petición típica tiene que ocurrir antes de responder: registrar la analítica, invalidar cachés, recalcular agregados, avisar a un sistema externo. ctx.waitUntil mantiene vivo el trabajo después de devolver la respuesta, y una Queue va más lejos: convierte trabajo síncrono en un mensaje que se procesa en lotes, con reintentos y cola de fallidos, sin que ninguna petición lo espere.

La distinción entre los dos merece precisión, porque se usan mal con frecuencia. ctx.waitUntil sirve para trabajo que puede perderse sin consecuencias graves y que termina en segundos: si el punto de presencia falla, ese trabajo desaparece y nadie se entera. Una Queue sirve para trabajo que debe ocurrir: se persiste antes de confirmarse, se reintenta con retroceso y lo que fracasa repetidamente acaba en una cola de fallidos donde alguien puede mirarlo. Enviar una factura con waitUntil es un error que solo se descubre el día que se pierde una.

Para los subrequests, el patrón es acotar el abanico en oleadas que respeten el techo de seis conexiones, ni todo en serie ni todo en paralelo:

// Ni cien llamadas a la vez ni cien en fila: oleadas del tamano del techo.
async function enLotes<T, R>(items: T[], tam: number, fn: (t: T) => Promise<R>) {
  const salida: R[] = [];
  for (let i = 0; i < items.length; i += tam) {
    salida.push(...(await Promise.all(items.slice(i, i + tam).map(fn))));
  }
  return salida;
}

Para la consistencia, el patrón es elegir la pieza por la garantía y no por la comodidad. Un dato que se lee mucho y cambia poco tolera desfase y va a KV. Un dato que el usuario acaba de escribir y quiere ver debe leerse de forma que le devuelva su propia escritura, y para eso está la Sessions API de D1. Un dato por el que dos peticiones pueden pelear no admite desfase de ningún tipo y necesita el hilo único de un Durable Object. La consistencia eventual solo hace daño cuando se aplica a un hecho que no la tolera, y eso es una decisión tuya, no una limitación de la plataforma.

⚠️
El síntoma de estar peleándose con un límite es el código defensivo

Cuando ves reintentos manuales para compensar lecturas rancias, marcas de tiempo comparadas a mano para decidir qué copia es la buena, o troceo artificial de un cálculo en piezas que caben pero que ya no tienen sentido semántico, no estás optimizando: estás construyendo a mano una garantía que otra pieza te daría gratis. Ese código nunca queda bien, porque intenta reconstruir en la capa de aplicación algo que solo se puede resolver en la capa de almacenamiento.

Las tres salidas

Cuando la carga no cabe de verdad, hay tres puertas y cada una responde a una forma distinta de no caber.

Los Containers son la salida cuando el problema es el entorno: un binario que ya existe, un lenguaje sin runtime en Workers, una biblioteca nativa, un sistema de ficheros real, mucha CPU o mucha memoria. Ejecutas una imagen linux/amd64 gobernada desde un Worker a través de un Durable Object que le da identidad estable, eliges instance_type, se despliega cerca de la demanda y se duerme tras sleepAfter sin facturar. La forma correcta de usarlos es como backend del Worker, no como sustituto: el Worker sigue siendo el borde que recibe, autentica y enruta, y el contenedor hace exclusivamente el trabajo que no cabe en un isolate.

Los Workflows son la salida cuando el problema es el tiempo y la durabilidad. Un proceso que tarda horas, que atraviesa varios sistemas, que debe reanudarse donde se quedó si algo falla y que no puede repetir un paso ya ejecutado no es una petición larga: es una máquina de estados persistente. Cada step se ejecuta como mucho una vez y su resultado se guarda, de modo que un reintento no rehace el pago que ya se hizo. Intentar resolver eso con cpu_ms alto es confundir duración con durabilidad, que son problemas distintos.

La base de datos externa es la salida cuando el problema es el modelo de datos: consultas analíticas pesadas, extensiones que D1 no tiene, volúmenes que exceden lo razonable para SQLite, o simplemente un Postgres que ya existe y no se mueve. Hyperdrive es la pieza que hace viable esa salida desde el edge, con su pool de conexiones y su caché de consultas, y su efecto sobre la aritmética de la lección uno es directo: reduce el número de viajes y el coste de cada uno sin que tengas que tocar tu base.

Las tres salidas comparten una disciplina que conviene enunciar antes de tomar cualquiera de ellas: el Worker sigue siendo el borde. Quien sale por una puerta y traslada allí también la autenticación, el enrutado y la lógica de negocio no ha usado una salida, ha migrado de plataforma conservando la factura de las dos. La forma correcta es que el contenedor, el workflow o la base externa hagan exclusivamente aquello que motivó la salida, y que todo lo demás —identidad, límites, caché, composición— siga viviendo donde el modelo lo resuelve gratis.

Y hay un coste que ninguna de las tres evita y que conviene presupuestar: la salida reintroduce el aprovisionamiento. Un contenedor tiene tipo de instancia y tope de instancias simultáneas; un workflow tiene concurrencia y duración; una base externa tiene tamaño, conexiones y copias de seguridad. Todo eso es exactamente lo que el modelo de isolates te había quitado de encima, así que cada salida devuelve una parte del trabajo operativo que habías dejado de hacer. No es un argumento contra usarlas: es la razón por la que conviene usarlas solo donde de verdad hacen falta.

flowchart TD
A[la carga no cabe] --> B[que es lo que no cabe]
B --> C[el entorno de ejecucion]
B --> D[la duracion y la durabilidad]
B --> E[el modelo de datos]
C --> C1[Containers gobernados por el Worker]
D --> D1[Workflows con pasos que se ejecutan una vez]
E --> E1[base externa con Hyperdrive delante]
A --> F[antes de salir revisa]
F --> F1[se puede diferir con waitUntil o Queues]
F --> F2[se puede transformar en flujo]
F --> F3[el hecho tolera desfase o no]

El límite como diagnóstico

Merece la pena invertir la lectura habitual. Un límite no es un obstáculo entre tú y la solución correcta; es una señal barata sobre la solución que has elegido. La mayoría de las veces que un Worker agota la CPU, no está haciendo trabajo legítimamente intenso: está haciendo en el camino crítico algo que no tenía por qué esperar, o está recalculando en cada petición algo que podría cachear. La mayoría de las veces que un Worker se queda sin memoria, está acumulando en una variable lo que podría atravesarlo como flujo. La mayoría de las veces que alguien sufre por la consistencia eventual, ha puesto en KV un hecho que necesitaba orden.

Esa relación tiene un valor que no se aprecia hasta que has trabajado en plataformas sin techos: los límites te avisan pronto y en el entorno de desarrollo. Un servidor con dieciséis gigabytes de memoria no te dice nada cuando escribes código acumulativo; te deja llegar a producción y romperse un martes bajo carga real, cuando el diagnóstico es carísimo. Un isolate de 128 MB te lo dice el primer día con un fichero de prueba. El techo estrecho no es la parte molesta de la plataforma: es su mecanismo de realimentación más rápido.

Esa lectura sugiere una práctica concreta que casi nadie aplica y que cuesta muy poco: apretar los límites a propósito en desarrollo. Bajar cpu_ms a una fracción del valor real, probar con ficheros grandes en lugar de con el de ejemplo, medir el bundle en cada integración continua y ejecutar la ruta contra una réplica deliberadamente atrasada. Cada una de esas cuatro cosas convierte un fallo futuro y caro en un fallo presente y barato, que es la única transformación que de verdad importa en ingeniería de sistemas.

Hay, eso sí, una minoría legítima de casos donde el límite no diagnostica nada y simplemente tu carga es otra cosa. Transcodificar vídeo consume CPU porque transcodificar vídeo consume CPU. Un proceso de tres horas dura tres horas. Una consulta analítica sobre cien millones de filas es pesada por naturaleza. Reconocer esa minoría también es parte del oficio, y para eso existen las tres salidas: no como parches, sino como el reconocimiento explícito de que un isolate es una herramienta excelente y no universal.

📏

Tres límites y una semántica

CPU, memoria y bundle son cantidades que se negocian. La consistencia eventual no es una cuota: es una consecuencia de estar en todas partes.

🚿

Tubería, no depósito

El estilo que resuelve memoria y buena parte de la CPU es el mismo: que los datos atraviesen el Worker en lugar de residir en él.

🔱

Tres salidas, tres diagnósticos

Containers para lo que no cabe en el entorno, Workflows para lo que dura y debe sobrevivir, base externa para lo que el modelo relacional exige.

Los límites no son el precio de la plataforma: son su mecanismo de diseño

Existe una manera perezosa de leer esta lección, que consiste en anotar cuatro números y considerarlos el peaje que hay que pagar por la latencia global. Esa lectura pierde lo esencial, porque invierte la causalidad. Los límites no vinieron después de las capacidades como una restricción administrativa: son literalmente lo que las hace posibles. Cientos de miles de aplicaciones caben en la misma máquina porque ninguna puede monopolizar el procesador ni la memoria; el arranque en cero milisegundos existe porque el artefacto es pequeño y puede estar caliente en trescientos sitios; la lectura local en cualquier punto del planeta existe porque hay copias, y hay copias porque se acepta que puedan divergir un instante. Quita cualquiera de los cuatro límites y la propiedad correspondiente desaparece con él. Por eso la actitud productiva no es preguntarse cuándo los subirán, sino qué te está diciendo cada uno cuando lo tocas, y esa pregunta casi siempre tiene una respuesta útil sobre tu propio diseño: la CPU te dice que estás haciendo en el camino crítico algo que podía esperar; la memoria te dice que estás guardando lo que debía fluir; el bundle te dice que arrastras una dependencia que no examinaste; y la consistencia te dice que colocaste un hecho en una pieza cuya semántica no coincide con lo que ese hecho necesita. Hay algo más, y es lo que de verdad distingue a esta plataforma de las que te dejan hacer cualquier cosa: los cuatro avisan pronto, en desarrollo, con datos de juguete, cuando corregir cuesta una tarde. Un entorno permisivo no te ahorra esos problemas, solo te los cobra más tarde y con usuarios delante. Interiorizado esto, las tres salidas dejan de parecer una admisión de derrota y se ven como lo que son: la parte del sistema donde se reconoce con honestidad que el isolate resuelve el noventa y cinco por ciento de las cargas y que para el cinco restante hay una puerta explícita, gobernada desde el mismo sitio y con el mismo modelo mental, en lugar de una plataforma distinta y un equipo distinto.

⚔️ Diagnostica tus propios techos
  1. Mide el tiempo de CPU real de tu ruta más pesada y separa cuánto es cálculo y cuánto es espera de red.
  2. Reescribe una función que acumule un objeto en memoria para que lo atraviese como flujo y compara la huella.
  3. Toma un trabajo que hoy ocurre en el camino crítico y decide si va a ctx.waitUntil o a una cola, justificando cuál.
  4. Encuentra un hecho de tu sistema colocado en una pieza cuya garantía de consistencia no le corresponde.
  5. Elige una carga real que no quepa y argumenta cuál de las tres salidas es la correcta y por qué las otras dos no.