Guía para principiantes sobre Cloudflare Durable Objects
Cloudflare Durable Objects explicado: enrutamiento, estado de instancia única, almacenamiento SQLite y ejemplo TypeScript de limitador de tasa con Workers.
Un Durable Object es una única instancia de una clase de JavaScript que Cloudflare ejecuta en exactamente un lugar a la vez; cada petición que nombra a esa instancia se enruta hacia ella, sin importar en qué parte del mundo se haya originado, y la instancia lleva consigo su propio almacenamiento privado.
Si construyes un contador, un bloqueo o una lista de “quién está en esta sala” con Workers sin más, dos peticiones pueden acabar en desacuerdo. El Worker que atendió la primera petición y el que atendió la segunda pueden ser isolates distintos en ciudades distintas, sin memoria compartida entre ellos.
Esta guía explica el modelo de enrutamiento que hace funcionar a los Durable Objects y luego muestra el mínimo de TypeScript que lo demuestra, usando un limitador de tasa (rate limiter) por API key como único ejemplo transversal. Da por sentado que ya conoces los bindings, wrangler y el handler fetch; si necesitas esa base primero, empieza por la guía para principiantes de Cloudflare Workers de OpenReplay.
Puntos clave
- Los Durable Objects son una primitiva de cómputo que lleva consigo su propio almacenamiento privado, no un producto de almacenamiento que se lee desde un Worker; el almacenamiento solo es accesible desde el código que se ejecuta dentro del objeto.
- La cadena que se pasa a
env.BINDING.getByName(name)es la identidad del objeto: cada petición en toda la red de Cloudflare que pase esa misma cadena llega a la misma instancia en ejecución. - Cada Durable Object posee una base de datos SQLite embebida en el mismo hilo que su código, por lo que
this.ctx.storage.sql.exec()devuelve un cursor de forma síncrona y no necesitaawait. - Los campos de clase sobreviven entre peticiones consecutivas, pero se descartan cuando el objeto hiberna tras unos 10 segundos de inactividad; todo lo que deba sobrevivir pertenece a
ctx.storage. - Usa Workers KV cuando muchas ubicaciones leen los mismos datos y una escritura puede tardar en hacerse visible en todas partes; usa un Durable Object cuando varios clientes deben coincidir en el valor actual en el mismo instante.
¿Por qué fallan los Workers sin estado en la coordinación?
Un Worker no conserva nada entre peticiones. Dos llamadas pueden aterrizar en isolates distintos, en lugares distintos, y ninguna puede ver lo que hizo la otra, así que cualquier funcionalidad que necesite que peticiones consecutivas coincidan en un valor se rompe. La propia guía de diseño de Cloudflare para Durable Objects traza exactamente esa línea entre los Workers sin estado y la coordinación con estado.
Este es el limitador de tasa ingenuo que parece correcto y no lo es:
// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();
export default {
async fetch(request): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
const count = (hits.get(key) ?? 0) + 1;
hits.set(key, count);
return new Response(count > 10 ? "slow down" : "ok", {
status: count > 10 ? 429 : 200,
});
},
} satisfies ExportedHandler;
El Map a nivel de módulo vive en un solo isolate. Un cliente que envía 30 peticiones que aterrizan en tres isolates ve tres contadores independientes que se detienen cada uno en 10, y el límite nunca se aplica. Mover el contador a una base de datos externa arregla el uso compartido, pero introduce una condición de carrera de lectura-luego-escritura entre peticiones concurrentes. El problema no es dónde residen los datos; es que nada garantiza un único lugar donde la comprobación y la actualización ocurran juntas.
La idea central: un objeto por nombre, la misma instancia siempre
Los Durable Objects resuelven la coordinación dando a cada nombre exactamente una instancia en ejecución y enrutando hacia ella todas las peticiones para ese nombre. La página de conceptos expone tres propiedades detrás de esto: cada objeto responde a un nombre único a nivel mundial, su almacenamiento reside junto a él en vez de al otro lado de una red, y ejecuta una cosa a la vez, igual que hace JavaScript en la pestaña de un navegador.
De ese modelo se derivan tres propiedades:
- La identidad es el nombre. Tu Worker elige una cadena (una API key, un ID de sala, un ID de documento) y la plataforma la asigna a una instancia. Dos Workers en continentes distintos que pasen la misma cadena hablan con el mismo objeto.
- La creación es implícita. No hay una llamada de creación. La referencia de la API de namespace explica que un ID por sí solo no crea nada, y que los objetos no se construyen hasta que algo los alcanza realmente. En la práctica, el constructor se ejecuta cuando llega la primera llamada a un método sobre el stub.
- La ejecución es de un solo hilo. El código síncrono dentro de un método no puede ser interrumpido por otra petición. Otras peticiones solo pueden ejecutarse mientras tu código está esperando (
await) E/S que no sea de almacenamiento, comofetch().
Como cada objeto es un hilo en una máquina, el rendimiento escala horizontalmente y no verticalmente: un limitador de tasa debería ser un objeto por API key, nunca un objeto global para todo el tráfico.
¿Cómo se define y registra una clase Durable Object?
Un Durable Object es una clase exportada que extiende DurableObject de cloudflare:workers, recibe ctx y env en su constructor y expone sus métodos públicos a los Workers mediante RPC. La guía de inicio fija la firma del constructor como (ctx: DurableObjectState, env: Env) con una llamada obligatoria a super(ctx, env).
import { DurableObject } from "cloudflare:workers";
export class RateLimiter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
);
}
async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
const bucket = Math.floor(Date.now() / windowMs);
// No await between the read and the write: nothing else can run in between.
const row = this.ctx.storage.sql
.exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
.toArray()[0];
const count = row ? row.count + 1 : 1;
this.ctx.storage.sql.exec(
"INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
bucket, count, count
);
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
}
El SELECT y el upsert se ejecutan sin ningún await entre ellos, así que ninguna otra petición para esta API key puede colarse en medio. Ese único hecho es lo que la versión sin estado no podía ofrecer.
Registrar la clase requiere dos entradas en wrangler.jsonc. La entrada exports marca la clase como durable-object con almacenamiento sqlite y es lo que aprovisiona el namespace en el primer despliegue. La entrada durable_objects.bindings le da al Worker un manejador a través de env:
{
"durable_objects": {
"bindings": [
{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }
]
},
"exports": {
"RateLimiter": {
"type": "durable-object",
"storage": "sqlite"
}
}
}
Los ejemplos más antiguos registran las clases mediante un array migrations con new_sqlite_classes. Esa forma sigue siendo compatible con Workers existentes, pero exports es el método actual y ambos no pueden coexistir en un mismo archivo de configuración.
¿Cómo se llama a un Durable Object desde un Worker?
Un Worker accede a un Durable Object pidiéndole al binding un stub con getByName(name) y llamando después a los métodos públicos de la clase sobre ese stub como si fueran funciones asíncronas normales. Un stub es solo un manejador local: las llamadas que se hacen sobre él se reenvían a la única instancia dueña de ese nombre.
export default {
async fetch(request, env): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
// `key` is the object's identity. Same key, same instance, everywhere.
const stub = env.RATE_LIMITER.getByName(key);
const { allowed, remaining } = await stub.increment(10, 60_000);
return new Response(allowed ? "ok" : "slow down", {
status: allowed ? 200 : 429,
headers: { "x-ratelimit-remaining": String(remaining) },
});
},
} satisfies ExportedHandler<Env>;
La cadena que se pasa a getByName() es la clave de enrutamiento. getByName(name) es una forma abreviada del antiguo procedimiento en dos pasos idFromName(name) seguido de get(id), que aún verás en muchos ejemplos; ambos direccionan el mismo objeto. Llamar a métodos directamente sobre el stub como RPC requiere una fecha de compatibilidad de 2024-04-03 o posterior, algo que cualquier plantilla nueva cumple.
¿Dónde guarda su estado un Durable Object?
Cada Durable Object tiene dos tipos de estado: una base de datos SQLite embebida y privada que sobrevive a los reinicios, y campos de clase corrientes que solo viven mientras el objeto está en memoria. La API de almacenamiento SQLite se ejecuta en el mismo hilo que tu código, así que exec() devuelve un SqlStorageCursor de inmediato, sin await. Vacía ese cursor antes del siguiente await, usando .toArray(), .one() o un bucle: un cursor que queda abierto atravesando un await puede recoger filas escritas mientras tanto, incluidas escrituras que después se revierten.
Un campo de clase es la vía rápida. Añadir uno al limitador de tasa muestra la diferencia:
export class RateLimiter extends DurableObject<Env> {
// In-memory: fast, private to this instance, gone after hibernation.
private lastSeen = 0;
async increment(limit: number, windowMs: number) {
this.lastSeen = Date.now();
// ... SQLite read and write as before (durable)
}
}
lastSeen sobrevive entre peticiones consecutivas, pero la documentación del ciclo de vida indica que tras 10 segundos sin eventos entrantes (y sin temporizadores pendientes, WebSockets de API estándar ni fetch() en curso), el objeto hiberna y su memoria se descarta. Los despliegues y el mantenimiento del runtime también pueden reiniciarlo en cualquier momento. La tabla hits sobrevive a todo eso. Cada objeto respaldado por SQLite puede almacenar hasta 10 GB en Workers Paid, según la página de límites.
¿Deberías usar Workers KV o Durable Objects?
Usa Workers KV cuando muchas ubicaciones necesiten leer los mismos datos y sea aceptable que una escritura tarde en hacerse visible en todas partes; usa un Durable Object cuando varios clientes deban coincidir en el valor actual en el mismo instante. La documentación sobre consistencia de KV es tajante sobre el compromiso: KV renuncia a la consistencia a cambio de velocidad, un cambio puede tardar un minuto o más en aparecer en otras ubicaciones, y todo lo que necesite una lectura y una escritura atómicas conjuntas debería usar Durable Objects en su lugar.
| Workers KV | Durable Objects | |
|---|---|---|
| Consistencia | Eventual; las copias en caché expiran según un TTL | Fuerte; una única instancia posee los datos |
| Dónde ocurren las lecturas | Cualquier ubicación, desde la caché | Dentro de la única instancia propietaria |
| Seguridad de lectura-luego-escritura | Ninguna entre peticiones | Garantizada si no hay await intermedio ajeno al almacenamiento |
| Patrón de escritura | Escrituras poco frecuentes por clave | Escrituras por objeto, serializadas por el runtime |
| Uso típico | Configuración, feature flags, listas de permitidos | Contadores, bloqueos, salas, estado por entidad |
La comparativa de opciones de almacenamiento clasifica ambos de la misma manera: KV cubre configuración y valores similares que se leen mucho más a menudo de lo que cambian, mientras que los Durable Objects cubren la coordinación entre clientes y el almacenamiento que se mantiene consistente por objeto. Un limitador de tasa es un contador de lectura-luego-escritura, así que pertenece a un Durable Object. Un feature flag por tenant que se lee en cada petición pertenece a KV.
Conclusión
Los Durable Objects resuelven la coordinación eliminando la cuestión de dónde vive el estado: el nombre que pasas a getByName() selecciona exactamente una instancia en ejecución, su base de datos SQLite reside en el mismo hilo que su código, y una lectura seguida de una escritura sin ningún await ajeno al almacenamiento entre ambas no puede intercalarse con nada. El siguiente paso es generar el andamiaje de la plantilla Worker + Durable Objects con npm create cloudflare@latest, sustituir la clase generada por el limitador de tasa anterior y ejecutar npx wrangler dev para ver cómo sube el contador a través de peticiones que de otro modo nunca coincidirían.
Preguntas frecuentes
¿Cuál es la diferencia entre Durable Objects y D1?
D1 es una base de datos SQLite gestionada que tu Worker consulta a través de la red, con una API HTTP y migraciones de esquema integradas. La base de datos SQLite de un Durable Object se ejecuta en la misma máquina que el código del objeto y solo es accesible desde los Workers a través de ese objeto. Ambas tienen un tope de 10 GB por base de datos en el plan Workers Paid, y ambas tienen topes menores en el plan gratuito. Usa D1 para una única base de datos relacional compartida; usa Durable Objects para estado por usuario o por entidad que necesite coordinación.
¿Cuántas peticiones por segundo puede manejar un solo Durable Object?
Un solo Durable Object tiene un límite blando de unas 1.000 peticiones por segundo, porque cada objeto se ejecuta en un hilo en una máquina. Por encima de eso, el runtime encola lo que puede y luego rechaza las llamadas adicionales con un error de sobrecarga. Cada invocación dispone de 30 segundos de tiempo de CPU por defecto, configurable hasta 5 minutos con limits.cpu_ms en la configuración de Wrangler. Escala horizontalmente con un objeto por nombre, por ejemplo un objeto por API key.
¿Funcionan los Durable Objects en el plan gratuito de Workers?
Sí. Los Durable Objects con el backend de almacenamiento SQLite están disponibles en el plan gratuito de Workers, con un tope de 1 GB por objeto, 5 GB de almacenamiento total de Durable Objects por cuenta y 100 clases de Durable Object. Workers Paid eleva estos límites a 10 GB por objeto, almacenamiento de cuenta ilimitado y 500 clases. Una vez que un objeto se llena, las escrituras fallan con un error SQLITE_FULL, aunque todavía puedes leer filas y eliminarlas para liberar espacio.
¿Necesito transacciones explícitas con sql.exec() en un Durable Object?
Normalmente no. Cada llamada a sql.exec() ya se ejecuta dentro de su propia transacción, y las lecturas y escrituras que se suceden sin ningún await entre medias se confirman como un único lote atómico, así que la lectura-luego-escritura de un limitador de tasa es segura tal como está escrita. sql.exec() no puede ejecutar sentencias BEGIN TRANSACTION ni SAVEPOINT. Para agrupar varias sentencias de modo que todas se reviertan si una lanza una excepción, usa ctx.storage.transactionSync(callback). El callback debe ser completamente síncrono: no declarado como async y sin devolver ninguna Promise.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k