Memoria y subrequests
Dos límites que rara vez se explican juntos pero que moldean el mismo tipo de decisión: cuánta memoria puede consumir un isolate —128 MB, compartidos entre todas las peticiones que atiende a la vez— y cuántas llamadas salientes admite una invocación. Vemos por qué la memoria es por isolate y no por petición, qué cuenta como subrequest, el techo de seis conexiones simultáneas, y cómo diseñar streaming y abanicos de llamadas que quepan dentro de ambos.
El límite de CPU habla del tiempo; estos dos hablan del espacio y de la conversación. Cuánta memoria puede reservar tu Worker mientras trabaja, y cuántas llamadas salientes puede lanzar por cada petición que atiende. Los dos parecen números sueltos que memorizar, pero esconden una misma enseñanza: en un runtime donde miles de inquilinos comparten un proceso, tu presupuesto no es una máquina entera reservada para ti, sino una porción que debes gastar con la disciplina de quien sabe que el vecino respira el mismo aire.
- Entender que los 128 MB de memoria son por isolate y se comparten entre las peticiones concurrentes que atiende.
- Definir con precisión qué operaciones cuentan como subrequest y cuántas admite cada plan.
- Reconocer el techo de seis conexiones salientes simultáneas y cómo condiciona un abanico de llamadas.
- Diseñar con streaming y con concurrencia acotada para vivir holgadamente dentro de ambos límites.
128 MB por isolate, no por petición
Cada isolate puede consumir hasta 128 megabytes de memoria, y ahí dentro cabe todo: el heap de JavaScript y las asignaciones de WebAssembly. El matiz que lo cambia todo es que ese límite es por isolate, no por invocación. Un mismo isolate atiende muchas peticiones a la vez, y todas ellas comparten esos 128 MB.
La consecuencia es sutil y peligrosa. Un endpoint que carga en memoria un fichero de 40 MB puede funcionar de maravilla cuando lo pruebas solo, y reventar en producción cuando tres peticiones coinciden en el mismo isolate y suman 120. No razonas sobre el pico de una petición aislada, sino sobre la suma de las que pueden solaparse.
Cuando un isolate supera los 128 MB, el runtime deja terminar las peticiones en vuelo y crea un isolate nuevo para las siguientes; bajo carga extrema, puede llegar a cancelar peticiones entrantes para mantener la estabilidad. En tu código, el síntoma es el error de memoria excedida.
Como una variable de módulo persiste entre peticiones del mismo isolate, es tentador usarla como caché. Pero cada entrada que acumulas ahí ocupa parte de los 128 MB compartidos y no se recupera al terminar la petición. Un mapa global que solo crece es una fuga de memoria lenta que acaba matando el isolate. Si cacheas en memoria, acota el tamaño y expulsa lo viejo.
Qué es un subrequest y cuántos caben
Un subrequest es cualquier petición que tu Worker lanza hacia afuera: una llamada con fetch() a un recurso de Internet, o una operación contra un servicio de Cloudflare como R2, KV o D1. Todas cuentan contra el mismo presupuesto.
Los números dependen del plan. En Free tienes 50 subrequests externos por invocación, más 1000 hacia servicios internos de Cloudflare. En Paid, el límite por defecto es de 10000 por invocación —el viejo tope de 1000 se levantó en 2026— y puedes subirlo hasta diez millones con limits.subrequests para cargas largas como websockets sobre Durable Objects o Workflows extensos.
{
"limits": {
// sube el techo de subrequests para cargas muy largas
"subrequests": 50000
}
}
Dos precisiones importan. La primera: cada salto de una cadena de redirecciones cuenta como un subrequest, así que el total puede superar el número de llamadas fetch() que ves escritas en tu código. La segunda, y más determinante para el diseño: solo puedes tener seis conexiones salientes abiertas a la vez. Puedes hacer diez mil subrequests a lo largo de una petición, pero no más de seis viajando en paralelo en un instante dado.
// abanico de 100 llamadas, pero como maximo 6 a la vez
async function enLotes<T>(tareas: (() => Promise<T>)[], limite = 6) {
const salida: T[] = [];
for (let i = 0; i < tareas.length; i += limite) {
const lote = tareas.slice(i, i + limite).map((t) => t());
salida.push(...(await Promise.all(lote)));
}
return salida;
}
Las llamadas a la Cache API —put(), match(), delete()— consumen del mismo cupo que los subrequests. No las contabilices aparte: un Worker que cachea agresivamente y además llama a varias APIs gasta de una sola bolsa común.
Diseñar dentro de los dos límites
La memoria y los subrequests empujan hacia el mismo estilo de código, y no es casualidad: los dos premian no acumular. La técnica central es el streaming. En vez de traer un objeto entero de R2 a memoria para transformarlo y devolverlo, encadenas el cuerpo de la respuesta como un flujo que pasa por el Worker sin residir del todo en él.
export default {
async fetch(request, env): Promise<Response> {
const objeto = await env.MI_BUCKET.get("video-grande.mp4");
if (!objeto) return new Response("no encontrado", { status: 404 });
// el cuerpo fluye a traves del Worker: no se acumula en los 128 MB
return new Response(objeto.body, {
headers: { "content-type": "video/mp4" },
});
},
};
Para los subrequests, el patrón es acotar el abanico. Un Worker que consulta cien recursos no debe dispararlos todos a la vez —chocaría contra las seis conexiones— ni uno tras otro en serie —desperdiciaría la latencia—: los agrupa en lotes de seis, como en la función de más arriba. Y el trabajo que deba sobrevivir a la respuesta, como escribir métricas, lo entrega a ctx.waitUntil() para no bloquear al usuario.
flowchart TB
ISO[Un isolate con 128 MB compartidos] --> R1[Peticion A en vuelo]
ISO --> R2[Peticion B en vuelo]
ISO --> R3[Peticion C en vuelo]
R1 --> SUM[La memoria usada es la suma de todas]
R2 --> SUM
R3 --> SUM
SUM --> GATE{Supera 128 MB}
GATE -- si --> KILL[Isolate reciclado y peticiones nuevas a otro]
GATE -- no --> OK[Sigue atendiendo]
style ISO fill:#89b4fa,color:#11111b
style SUM fill:#fab387,color:#11111b
style KILL fill:#f38ba8,color:#11111b
style OK fill:#a6e3a1,color:#11111bLa memoria y los subrequests parecen dos restricciones administrativas, pero juntas empujan tu código hacia una filosofía concreta, y esa filosofía es la que separa al que programa para el edge del que arrastra hábitos del servidor. En una máquina que reservas entera, la memoria abundante invita a un estilo acumulativo: traes el fichero completo, lo guardas en una variable, lo manipulas a placer, porque el espacio es tuyo y nadie te lo disputa. El isolate niega ese lujo por partida doble. Primero, porque sus 128 MB no son tuyos sino del isolate, compartidos con cada petición que coincida contigo, de modo que tu huella de memoria deja de ser un problema individual y pasa a ser un bien común que puedes agotar para todos. Y segundo, porque el modelo entero está afinado para trabajo que fluye, no para trabajo que reposa: el streaming existe precisamente para que los bytes atraviesen tu Worker sin habitarlo, para que seas una tubería y no un depósito. Los subrequests refuerzan la misma lección desde el otro flanco: las seis conexiones simultáneas te prohíben el abanico ingenuo de mil llamadas paralelas y te obligan a pensar en oleadas, en ritmo, en presión controlada sobre los sistemas que consultas. Cuando interiorizas las dos cosas, dejas de preguntar “cuánto cabe en mi Worker” —la pregunta de quien piensa en almacenes— y empiezas a preguntar “cómo hago que los datos pasen por mi Worker sin quedarse” —la pregunta de quien piensa en flujos—. Ese cambio de almacén a flujo no es una optimización marginal: es el modo natural de habitar un runtime donde nada es solo tuyo.
- Calcula cuántas peticiones que cargan un blob de 30 MB en memoria pueden coincidir en un isolate antes de rozar los 128 MB, y explica por qué probar en solitario no lo detecta.
- Reescribe un endpoint que hace
arrayBuffer()de un objeto de R2 para que en su lugar transmita elbodycomo flujo. - Escribe un abanico de 40 subrequests que respete el techo de seis conexiones simultáneas usando lotes.
- Explica por qué una caché en memoria basada en un objeto global que solo crece acaba reciclando el isolate.
- Razona por qué las llamadas a la Cache API deben contarse dentro del mismo presupuesto que los
fetch().