effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube

Domina el error overloaded de Durable Objects: cómo evitar el soft limit de 1.000 solicitudes por segundo y el cuello de botella del hilo único

Domina el error overloaded de Durable Objects: cómo evitar el soft limit de 1.000 solicitudes por segundo y el cuello de botella del hilo único

Durable Objects (DO) hace una promesa muy atractiva: “una única instancia global garantiza consistencia fuerte”. Es el modelo perfecto para dominios donde el orden importa, como salas de chat, sesiones de juego o máquinas de estado de pagos. El problema aparece cuando diseñas olvidando la razón por la que esa promesa se cumple: una instancia de DO se ejecuta físicamente en un único hilo. Con el aumento de tráfico, de repente empiezan a llover errores overloaded junto con 502, una API externa lenta acaba retrasando a todos los demás usuarios de la misma sala, y la lógica de reintento de alarm() termina cobrando un pago dos veces.

Este artículo repasa estas tres trampas con cifras de la documentación oficial y código reproducible.

Resumen clave

  • Cada instancia de DO tiene un soft limit de 1.000 solicitudes por segundo, y si las solicitudes se acumulan sobre el mismo objeto dentro de una ventana de 10 segundos, se dispara el error Too many requests for the same object within a 10 second window.
  • await fetch() abre el input gate y permite el interleaving con otras solicitudes, pero las operaciones await storage.* y blockConcurrencyWhile() se serializan por completo, creando un verdadero head-of-line blocking.
  • alarm() solo reintenta automáticamente hasta 6 veces con backoff exponencial a partir de 2 segundos. Si el side effect ejecutado durante el reintento (pago, webhook) no es idempotente, se ejecuta por duplicado.
  • Los DO basados en SQLite tienen un límite de almacenamiento de 1GB en Free / 10GB en Paid por objeto, y al superarlo las escrituras fallan con SQLITE_FULL. Desde el 20 de julio de 2026, el dashboard incluye un gráfico de Total storage por namespace.

Por qué DO es de un solo hilo, y la latencia que genera el procesamiento secuencial

El contrato central de un Durable Object es que “el objeto creado con un mismo ID se ejecuta en un único lugar físico en todo el mundo, y todas las solicitudes que llegan a ese lugar son procesadas en orden por esa instancia”. La documentación oficial de Cloudflare ilustra esta propiedad con el ejemplo de un sistema de reservas.

“todas las solicitudes de reserva para un local deben serializarse para evitar el double-booking”

Esa serialización es precisamente lo que permite evitar reservas duplicadas sin locks ni transacciones distribuidas. Pero no es gratis: toda ejecución síncrona dirigida al mismo objeto tiene que hacer cola sobre la línea temporal de un único hilo de JS. Veamos un DO contador muy simple.

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  async fetch(req) {
    let count = (await this.ctx.storage.get("count")) ?? 0;
    count += 1;
    await this.ctx.storage.put("count", count);
    return Response.json({ count });
  }
}

Este código en sí parece inofensivo, pero la historia cambia cuando llegan cientos de solicitudes por segundo con el mismo id. La guía práctica de Cloudflare deja fijado el throughput de un solo objeto en estos términos.

Un único Durable Object puede procesar aproximadamente entre 500 y 1.000 solicitudes por segundo, dependiendo de la complejidad computacional.

Es decir, decir que “DO escala infinitamente” solo es cierto a medias. El número de objetos puede crecer sin límite, pero el throughput de un solo objeto nunca supera el techo de un único núcleo de CPU. Un diseño de chat con un DO por sala funciona de maravilla dentro de ese límite, pero en el momento en que concentras todo el tráfico en un solo objeto —un “contador global único”, un “rate limiter global único”— se convierte en un cuello de botella.

El error ‘overloaded’: condiciones exactas y cómo reproducirlo

El soft limit oficial y los 4 mensajes de error

La documentación oficial de límites de Cloudflare lo especifica así.

“Un objeto individual tiene un soft limit de 1.000 solicitudes por segundo.” “Un Durable Object que recibe demasiadas solicitudes, tras intentar encolarlas, devolverá al llamante un error overloaded.”

La expresión “soft limit” es clave aquí. No es un hard limit que bloquea de inmediato al superar las 1.000 req/s, sino un mecanismo que, desde el momento en que se supera, intenta encolar las solicitudes y solo lanza el error cuando la propia cola no puede más. De hecho, la documentación de troubleshooting desglosa el error overloaded en 4 mensajes distintos según la causa.

Este último mensaje es la condición de “ventana de 10 segundos” mencionada en el título del artículo. La documentación explica que este mensaje “no sustituye a los demás mensajes de overloaded, y solo se devuelve en situaciones de sobrecarga aún más extremas”. En otras palabras, si ves este mensaje es que ya se ha producido una concentración de tráfico bastante grave.

Otra causa fácil de pasar por alto no es el número de solicitudes en sí, sino la latencia de I/O. La documentación de manejo de errores lo expresa así.

Las causas de las excepciones de infraestructura incluyen no solo “un exceso de solicitudes dirigidas a un único Durable Object”, sino también “la acumulación de solicitudes en cola debido a I/O lenta o excesiva”.

Es decir, aunque el número de solicitudes por segundo sea muy inferior a 1.000, si cada solicitud tarda mucho esperando una API externa lenta, la cola puede acumularse y aparecer el error overloaded.

Cómo reproducirlo

Meter carga real en producción es arriesgado, así que aquí tienes código para reproducirlo en un entorno de staging. La clave está en concentrar solicitudes en poco tiempo usando el mismo id de DO.

// worker.js — 부하 발생용 엔드포인트 (스테이징 전용)
export default {
  async fetch(req, env) {
    const url = new URL(req.url);
    if (url.pathname === "/hammer") {
      const id = env.COUNTER.idFromName("shared-hot-object");
      const stub = env.COUNTER.get(id);

      const batch = Array.from({ length: 500 }, () =>
        stub.fetch("https://do/increment").catch((e) => e)
      );
      const results = await Promise.allSettled(batch);
      const overloaded = results.filter(
        (r) => r.status === "fulfilled" && r.value?.status === 500
      );
      return Response.json({ sent: batch.length, overloaded: overloaded.length });
    }
    return new Response("ok");
  },
};

Hay un detalle importante. El límite de subrequests de Workers es de 50 por solicitud en el plan Free y 10.000 por solicitud en el plan Paid, y el número de conexiones salientes que pueden estar abiertas simultáneamente mientras se espera la cabecera de respuesta está limitado a 6, independientemente del plan. Es decir, aunque parezca que Promise.all dispara miles de solicitudes de golpe, en realidad se procesan de 6 en 6, y en el plan Free ni siquiera se pueden enviar más de 50 por solicitud. Si de verdad quieres generar 1.000 req/s, es mucho más realista atacar el endpoint /hammer con una herramienta de carga externa como autocannon o hey, disparando varias llamadas al Worker en paralelo.

# 외부에서 동시성 100으로 10초간 /hammer 호출
npx autocannon -c 100 -d 10 https://your-worker.workers.dev/hammer

De esta forma, cada llamada al Worker envía 500 fetch al mismo DO, y al solaparse las llamadas concurrentes puedes observar de verdad el error Too many requests for the same object within a 10 second window. El objeto de error lleva adjunta una propiedad .overloaded, así que en el cliente conviene distinguirlo así.

try {
  const resp = await stub.fetch(req);
  return resp;
} catch (e: any) {
  if (e.overloaded) {
    // 재시도하면 과부하가 더 심해진다 — 재시도 금지, 즉시 실패 처리
    return new Response("busy", { status: 503 });
  }
  if (e.retryable) {
    // 멱등 요청이라면 지수 백오프로 재시도 가능
  }
  throw e;
}

La documentación oficial también lo advierte con claridad: reintentar un error con .overloaded en true “empeora la sobrecarga y aumenta la tasa de error global”.

El head-of-line blocking que provocan las llamadas lentas a APIs externas

Qué hacen realmente el input gate y el output gate

Aquí es donde más malentendidos hay. Es tentador pensar que “como DO es de un solo hilo, si llamas a una API externa con await fetch() se detienen todas las demás solicitudes dirigidas a ese objeto”, pero no es exacto. Cloudflare matiza esto mediante un mecanismo de input gate / output gate.

Es decir, hacer await de una sola API externa no detiene todo el objeto. El head-of-line blocking real, el que arrastra a otras solicitudes, aparece en estos tres patrones.

Los 3 patrones realmente peligrosos

1) Meter una llamada externa lenta dentro de blockConcurrencyWhile(). Este método encola todos los eventos —salvo los generados por el propio callback— hasta que el callback termina. Un error habitual es llamar a una API externa en el constructor con la idea de “precalentar la caché”.

export class RoomState extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.ctx.blockConcurrencyWhile(async () => {
      // 안티패턴: 느린 외부 API를 초기화 블록 안에서 기다림
      this.config = await fetch("https://config-service.example.com/room").then((r) => r.json());
    });
  }
}

Si ese servicio de configuración se vuelve lento o se cae un momento, todas las solicitudes dirigidas a este objeto quedan a la espera hasta que termina el callback. La documentación oficial especifica que blockConcurrencyWhile tiene un timeout de 30 segundos, y que si se supera “se reinicia el Durable Object”. Si el callback lanza una excepción ocurre lo mismo: el objeto se termina y se reinicia. La recomendación es clara: “el callback debe hacer el menor trabajo posible para que el throughput general de solicitudes sea bueno”. Como el almacenamiento basado en SQLite es atómico en sus operaciones, en el procesamiento normal de solicitudes rara vez hace falta blockConcurrencyWhile, y lo más seguro es limitar su uso a casos como migraciones de esquema en el constructor.

2) Acumulación de operaciones de storage. A diferencia de await fetch(), una llamada como await this.ctx.storage.get(...) no abre el input gate. Es decir, si la causa es I/O de storage, las solicitudes sí que hacen cola de verdad. El remedio práctico aquí es agrupar en batch en lugar de hacer get() de cada clave por separado.

// 느림: N번의 개별 왕복, 그동안 input gate가 계속 닫힘
for (const key of keys) {
  await this.ctx.storage.get(key);
}

// 빠름: 한 번의 왕복
const values = await this.ctx.storage.get(keys); // keys: Array<string>

3) Cómputo síncrono de CPU. Mientras parseas un JSON grande o ejecutas un bucle pesado no hay ningún punto de await, así que, independientemente del input gate, el propio event loop se bloquea sin más. Lo correcto es mover la lógica que procesa payloads grandes fuera del DO, hacia el Worker, o trocearla en chunks.

Patrón de solución: sharding para distribuir solicitudes entre varias instancias de DO

Las dos secciones anteriores convergen en una sola conclusión: como no se puede aumentar la capacidad de un objeto, hay que aumentar el número de objetos. La guía oficial señala explícitamente como antipatrón “un único Durable Object global que gestiona todas las salas de chat”, y formula así el número de objetos necesarios.

Número de DO necesarios = (total de solicitudes por segundo) / (solicitudes que puede procesar un solo DO)

Por ejemplo, si un servicio de sesiones de juego recibe 500.000 solicitudes por segundo y un objeto procesa entre 500 y 1.000 por segundo, eso significa que hacen falta entre 500 y 1.000 DO por sesión, no un único coordinador global. Implementar un rate limiter global con un solo DO es peligroso por la misma razón: se convierte en un “chokepoint” donde converge todo el tráfico, y no escala.

En la práctica, el sharding se hace con claves de partición naturales como el ID de usuario, el ID de sala o el ID de tenant.

// 안티패턴: 전역 싱글턴
const id = env.RATE_LIMITER.idFromName("global");

// 개선: 사용자 단위로 샤딩 — 사용자마다 독립된 오브젝트
const id = env.RATE_LIMITER.idFromName(`user:${userId}`);

// 더 세밀하게: 해시로 N개 버킷에 분산 (전역 카운터를 근사치로 집계할 때)
function shardId(key, shardCount = 64) {
  let hash = 0;
  for (let i = 0; i < key.length; i++) {
    hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
  }
  return `shard:${hash % shardCount}`;
}
const id = env.COUNTER.idFromName(shardId(userId));

Por supuesto, esto tiene un trade-off. Con sharding, la lógica que necesita un “total global exacto” (por ejemplo, el número total de conexiones simultáneas) deja de poder leerse al instante desde un único objeto. En estos casos lo habitual es adoptar un patrón fan-in: cada shard reporta periódicamente su recuento, mediante alarm, a un DO de agregación de nivel superior, y se acepta una aproximación con un margen de unos pocos segundos en lugar de una precisión en tiempo real.

Los reintentos con backoff exponencial de alarm() y los bugs de ejecución duplicada por falta de idempotencia

Por qué “hasta 6 reintentos” es una trampa

alarm() es la API que ofrece ejecución programada fiable dentro de un DO. La documentación oficial define así las condiciones de esa fiabilidad.

“El handler alarm() garantiza una ejecución at-least-once y, en caso de fallo, se reintenta con backoff exponencial, empezando con un retraso de 2 segundos, hasta un máximo de 6 reintentos.”

Es decir, reintenta automáticamente como máximo 6 veces, con intervalos de 2 → 4 → 8 → 16 → 32 → 64 segundos. Y hay otra frase realmente importante aquí.

“Si un error inesperado termina el Durable Object, el handler alarm() puede volver a instanciarse en otra máquina. Tras un breve retraso, el handler alarm() se ejecutará desde el principio en esa otra máquina.”

Esta frase es la clave del bug de ejecución duplicada. alarm() no se reintenta solo cuando lanza una excepción: si el propio proceso del DO muere (por un fallo de infraestructura, agotamiento de recursos, una terminación inesperada, etc.), se vuelve a ejecutar desde el principio. Es decir, es perfectamente posible el escenario en el que, a mitad del handler de alarm(), “la llamada de pago tiene éxito, pero el proceso muere justo antes de guardar el siguiente estado”, y en ese caso el alarm() re-ejecutado vuelve a realizar una llamada de pago que ya había tenido éxito.

// 위험한 패턴 — 멱등성 없음
async alarm() {
  const order = await this.ctx.storage.get("pendingOrder");
  if (!order) return;

  // ① 이 fetch가 성공한 직후, ②로 가기 전에 프로세스가 죽으면?
  await fetch("https://payments.example.com/charge", {
    method: "POST",
    body: JSON.stringify(order),
  });

  // ② 여기 도달하지 못하면 다음 실행에서 ①이 다시 일어난다
  await this.ctx.storage.delete("pendingOrder");
}

La documentación especifica que llamar a deleteAlarm() dentro del handler de alarm() “puede evitar el reintento de forma best-effort, pero no está garantizado”. En otras palabras, deleteAlarm() no sirve como medida de idempotencia.

Patrones para garantizar la idempotencia

El enfoque más práctico es combinar hacer idempotente la propia llamada externa con confirmar el estado antes de hacer la llamada.

async alarm(alarmInfo) {
  const order = await this.ctx.storage.get("pendingOrder");
  if (!order || order.status === "charged") return;

  if (alarmInfo?.isRetry) {
    console.log(`retry #${alarmInfo.retryCount}, resuming order ${order.id}`);
  }

  // 1. 외부 호출 전에 "처리 중" 상태를 먼저 스토리지에 커밋
  //    (order.idempotencyKey는 최초 생성 시 한 번만 발급)
  await fetch("https://payments.example.com/charge", {
    method: "POST",
    headers: { "Idempotency-Key": order.idempotencyKey },
    body: JSON.stringify(order),
  });

  // 2. 성공 후에만 완료 상태로 전이
  order.status = "charged";
  await this.ctx.storage.put("pendingOrder", order);
  await this.ctx.storage.delete("pendingOrder"); // 정리
}

Hay tres puntos clave.

Límites de almacenamiento y monitorización en los DO basados en SQLite

Límites de Free/Paid

Durable Objects usa ahora almacenamiento basado en SQLite por defecto. Según la documentación oficial de límites, estos son los topes.

¿Qué pasa al superar el límite? La documentación oficial lo explica con precisión.

Cuando un objeto alcanza su límite máximo de almacenamiento (10GB en Paid, 1GB en Free), las operaciones de escritura como INSERT, UPDATE, put() o sql.exec() fallan con el error database or disk is full: SQLITE_FULL. Las operaciones de lectura como SELECT, get() o list(), y también DELETE, siguen funcionando, de modo que se puede liberar espacio.

Es decir, superar el límite no supone una caída total del servicio, sino un estado en el que “solo se bloquean las escrituras, y es recuperable borrando datos”. Es más seguro escribir el código de manejo así.

try {
  this.ctx.storage.sql.exec(
    "INSERT INTO my_table (key, value) VALUES (?, ?)",
    key,
    value,
  );
} catch (e) {
  if (e.message.includes("SQLITE_FULL")) {
    // 저장 한도 도달 — 읽기/삭제는 여전히 가능
    // 오래된 데이터를 정리하거나 호출자에게 의미 있는 에러를 반환
  }
  throw e;
}

Cómo comprobar el uso de antemano

Es mucho mejor detectarlo con una alerta antes de llegar al límite que gestionarlo después. La API de storage de SQLite expone una propiedad que permite leer directamente el tamaño actual de la base de datos en bytes.

async fetch(req) {
  const sizeBytes = this.ctx.storage.sql.databaseSize;
  const limitBytes = 10 * 1024 * 1024 * 1024; // Paid 플랜 10GB
  if (sizeBytes > limitBytes * 0.8) {
    console.warn(`storage at ${(sizeBytes / limitBytes * 100).toFixed(1)}% of limit`);
    // 알림 전송, 오래된 로우 정리 alarm 예약 등
  }
  // ...
}

Si quieres una vista de conjunto de toda la cuenta, Cloudflare añadió el 20 de julio de 2026 un gráfico de “Total storage” por namespace al dashboard de Durable Objects. Muestra el máximo de almacenamiento reportado por hora, y sirve para confirmar la tendencia de crecimiento, comprobar si una limpieza de datos ha surtido efecto de verdad, o detectar picos de uso inesperados. Eso sí, este gráfico solo aplica a namespaces basados en SQLite y todavía no permite seguimiento por objeto individual (por ID/name), así que para saber si un objeto concreto está cerca del límite hay que seguir comprobándolo directamente dentro del objeto, como en el código de databaseSize de arriba.

Para terminar: checklist de producción

En resumen, antes de llevar un DO a producción conviene revisar, como mínimo, lo siguiente.

Durable Objects no ofrece “consistencia fuerte” y “escalabilidad infinita” al mismo tiempo. La consistencia nace de la serialización de un solo hilo dentro de un único objeto, y la escalabilidad nace de lo bien que dividas ese objeto. Si eres consciente de este trade-off desde la fase de diseño, la mayoría de las trampas descritas en este artículo se pueden evitar de antemano.