Cloudflare Durable Objects a fondo: estado con coordinación, WebSockets y SQLite en cada objeto

Qué es un Durable Object, unicidad global y un solo hilo, RPC con getByName, almacenamiento SQLite síncrono, alarmas, WebSockets con hibernación, un limitador de peticiones y un chat probados en local, tests, precios por duración y límites.

Portada: Cloudflare Durable Objects a fondo: estado con coordinación, WebSockets y SQLite en cada objeto

Los Workers no tienen estado: cada petición puede caer en un isolate distinto, en un centro de datos distinto, y lo que guardes en una variable desaparece cuando quiera el runtime. Para casi todo eso es una virtud. Pero hay problemas que necesitan un único punto de coordinación: un contador que no pierda incrementos, un límite de peticiones por usuario, una sala de chat donde todos vean los mensajes en el mismo orden, un documento que editan varias personas a la vez.

Los Durable Objects son la respuesta de Cloudflare. Un Durable Object es una instancia de una clase tuya con un nombre, que existe una sola vez en todo el mundo, procesa sus peticiones de una en una y tiene su propio almacenamiento (una base de datos SQLite privada). Todas las peticiones que van a "la sala general" o a "el usuario 42" llegan al mismo objeto, estén donde estén los clientes.

Es la pieza más original de la plataforma y la que más cuesta entender al principio. En este artículo vamos a ver cómo funciona, cuándo merece la pena y dos ejemplos completos: un limitador de peticiones con alarmas y un chat con WebSockets que hiberna para no cobrar mientras nadie escribe, probados en local con Wrangler, un cliente WebSocket de Node y Vitest.

🧪
Todo el código de este artículo se ha ejecutado de verdad el 2 de octubre de 2026 con Wrangler 4.147.0, workerd 1.20261001.1 (Durable Objects en local con Miniflare), @cloudflare/vitest-plugin 1.3.6, Vitest 4.1.11 y Node.js 24.21.0 (su WebSocket global hace de cliente). Precios y límites comprobados en la documentación oficial ese mismo día.

Qué es un Durable Object

Piensa en una clase de JavaScript normal, con métodos y estado, pero con tres propiedades especiales:

  1. Unicidad global. Cada objeto tiene un identificador (que puedes derivar de un nombre, como "sala-general"). Para un identificador dado, hay como mucho una instancia activa en todo el mundo. Cloudflare la crea en el primer acceso, cerca de quien la pide, y desde ahí todas las peticiones a ese identificador van a esa instancia.
  2. Un solo hilo. El objeto procesa los eventos de uno en uno. No hay condiciones de carrera entre dos peticiones al mismo objeto: si tu método lee, modifica y escribe, nadie se cuela en medio (salvo que esperes algo externo, como un fetch).
  3. Almacenamiento propio y persistente. Cada objeto tiene su base de datos SQLite, en la misma máquina donde se ejecuta. Leer y escribir es síncrono y muy rápido, porque no hay red de por medio.

El patrón es siempre el mismo: tu Worker recibe la petición, decide a qué objeto va (por usuario, por sala, por documento) y lo llama. El objeto hace el trabajo que necesita coordinación.

Esquema de Durable Objects: clientes de varios países llegan a Workers distintos, que dirigen las peticiones de la sala general al mismo objeto, con su almacenamiento SQLite, mientras otras salas son objetos distintos
Un objeto por sala: todas las peticiones de una sala llegan a la misma instancia, esté donde esté cada cliente. Diagrama: devutilites.

Cómo escala

Un objeto individual es pequeño: un hilo y un límite blando de unas 1.000 peticiones por segundo. La escala viene del número: puedes tener millones de objetos, y el diseño correcto es uno por unidad de coordinación (uno por usuario, por sala, por documento), nunca "un objeto global para toda la aplicación".

Ciclo de vida

Un objeto está activo mientras procesa peticiones y unos segundos después; luego puede hibernar: sale de la memoria, pero su almacenamiento y sus conexiones WebSocket (si usa la API de hibernación) siguen ahí. En la siguiente petición se vuelve a construir (se ejecuta el constructor) y continúa. Lo que guardes en propiedades de la clase sirve como caché, pero lo que importa tiene que estar en el almacenamiento.

Lo que cuesta

Los Durable Objects están disponibles también en el plan gratuito, solo con el almacenamiento SQLite (el antiguo almacenamiento clave-valor es exclusivo de Paid). Se cobran tres cosas:

Workers FreeWorkers Paid
Peticiones (HTTP, RPC, mensajes WebSocket, alarmas)100.000 al día1 millón al mes, luego 0,15 $ por millón
Duración13.000 GB-s al día400.000 GB-s al mes, luego 12,50 $ por millón de GB-s
Filas leídas (SQLite)5 millones al día25.000 millones al mes, luego 0,001 $ por millón
Filas escritas (SQLite)100.000 al día50 millones al mes, luego 1,00 $ por millón
Almacenamiento SQLite5 GB en total5 GB-mes, luego 0,20 $ por GB-mes

La duración es la partida que hay que vigilar. Se cobra el tiempo de reloj (no de CPU) que el objeto pasa en memoria sin poder hibernar, multiplicado por 128 MB, uses o no esa memoria. Un objeto que atiende una petición de 50 ms y luego hiberna cuesta poco; un objeto que mantiene un WebSocket con accept() normal cobra cada segundo que la conexión está abierta, aunque no pase nada.

Dos detalles más del modelo de precios: los mensajes WebSocket entrantes se cuentan en proporción 20 a 1 (100 mensajes facturan como 5 peticiones), y los salientes no se cobran. La documentación calcula que 100 salas con 100 usuarios conectados cada una, enviando un mensaje por minuto con hibernación, salen a unos 20,65 $ al mes incluyendo los 5 $ del plan.

Configuración

Un Durable Object se declara con un binding y una migración: cada clase nueva, renombrada o borrada se registra en migrations con una etiqueta. new_sqlite_classes crea clases con almacenamiento SQLite, que es lo que debes usar siempre en proyectos nuevos.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "do-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "durable_objects": {
    "bindings": [
      { "name": "LIMITADOR", "class_name": "Limitador" },
      { "name": "SALAS", "class_name": "Sala" }
    ]
  },
  // Cada clase nueva se declara en una migración. new_sqlite_classes = almacenamiento SQLite
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Limitador", "Sala"] }
  ]
}
⚠️
Las migraciones no se editan, se añaden. Si mañana renombras Sala a Canal, no cambies la v1: añade { "tag": "v2", "renamed_classes": [{ "from": "Sala", "to": "Canal" }] }. Y si quitas una clase con deleted_classes, se borran todos sus datos.

Ejemplo 1: un limitador de peticiones

Un limitador por IP es el ejemplo perfecto de por qué existen los Durable Objects. Con KV, dos peticiones simultáneas leerían el mismo contador y lo dejarían mal. Con un objeto por IP, las peticiones se procesan en fila y la cuenta siempre es exacta.

Este es el código completo de los dos ejemplos (el Worker está al final):

import { DurableObject } from "cloudflare:workers";

// ─────────────────────────────────────────────────────────────
// 1) Limitador de peticiones: un objeto por usuario (o IP)
// ─────────────────────────────────────────────────────────────
export class Limitador extends DurableObject<Env> {
  // Métodos públicos = RPC: se llaman desde el Worker como funciones normales
  async consumir(maximo: number, ventanaMs: number): Promise<{ permitido: boolean; restantes: number }> {
    const ahora = Date.now();
    // Almacenamiento clave-valor (sobre SQLite): síncrono y transaccional
    const marcas = (this.ctx.storage.kv.get<number[]>("marcas") ?? []).filter((t) => ahora - t < ventanaMs);

    if (marcas.length >= maximo) return { permitido: false, restantes: 0 };

    marcas.push(ahora);
    this.ctx.storage.kv.put("marcas", marcas);

    // Programar limpieza: si nadie vuelve, la alarma borra el estado
    if ((await this.ctx.storage.getAlarm()) === null) {
      await this.ctx.storage.setAlarm(ahora + ventanaMs);
    }
    return { permitido: true, restantes: maximo - marcas.length };
  }

  async alarm(): Promise<void> {
    // Se ejecuta aunque no haya peticiones: el objeto "despierta" solo
    await this.ctx.storage.deleteAll();
  }
}

// ─────────────────────────────────────────────────────────────
// 2) Sala de chat: WebSockets con hibernación + historial en SQL
// ─────────────────────────────────────────────────────────────
type Mensaje = { autor: string; texto: string; enviado_en: string };

export class Sala extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // blockConcurrencyWhile: nada entra hasta que la tabla exista
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS mensajes (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          autor TEXT NOT NULL,
          texto TEXT NOT NULL,
          enviado_en TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
        )`);
    });
    // "ping" -> "pong" sin despertar al objeto (no cuenta como duración)
    ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
  }

  async fetch(request: Request): Promise<Response> {
    const autor = new URL(request.url).searchParams.get("nombre") ?? "anónimo";
    const { 0: cliente, 1: servidor } = new WebSocketPair();

    // acceptWebSocket (y no servidor.accept()) permite hibernar con la conexión abierta
    this.ctx.acceptWebSocket(servidor);
    servidor.serializeAttachment({ autor }); // sobrevive a la hibernación

    // Al entrar, el historial reciente
    for (const m of this.historial(20)) servidor.send(JSON.stringify(m));
    return new Response(null, { status: 101, webSocket: cliente });
  }

  async webSocketMessage(ws: WebSocket, mensaje: string | ArrayBuffer): Promise<void> {
    const { autor } = ws.deserializeAttachment() as { autor: string };
    const texto = typeof mensaje === "string" ? mensaje : new TextDecoder().decode(mensaje);

    const guardado = this.ctx.storage.sql
      .exec<Mensaje>("INSERT INTO mensajes (autor, texto) VALUES (?, ?) RETURNING autor, texto, enviado_en", autor, texto)
      .one();

    // Difundir a todos los conectados a ESTA sala
    for (const otro of this.ctx.getWebSockets()) otro.send(JSON.stringify(guardado));
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
    // Completar el cierre. 1005 ("sin código") y 1006 no se pueden enviar: usar 1000
    ws.close(code === 1005 || code === 1006 ? 1000 : code, reason);
  }

  // También accesible por RPC
  historial(limite = 50): Mensaje[] {
    return this.ctx.storage.sql
      .exec<Mensaje>("SELECT autor, texto, enviado_en FROM (SELECT * FROM mensajes ORDER BY id DESC LIMIT ?) ORDER BY id", limite)
      .toArray();
  }
}

// ─────────────────────────────────────────────────────────────
// El Worker: decide a qué objeto va cada petición
// ─────────────────────────────────────────────────────────────
export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);

    // GET /api/algo -> máximo 5 peticiones cada 10 s por IP
    if (url.pathname.startsWith("/api/")) {
      const ip = request.headers.get("CF-Connecting-IP") ?? "local";
      const limitador = env.LIMITADOR.getByName(ip); // mismo nombre => mismo objeto en todo el mundo
      const { permitido, restantes } = await limitador.consumir(5, 10_000);
      if (!permitido) return Response.json({ error: "Demasiadas peticiones" }, { status: 429, headers: { "Retry-After": "10" } });
      return Response.json({ ok: true, restantes });
    }

    // /salas/:nombre (WebSocket) y /salas/:nombre/historial (RPC)
    const [, seccion, nombre, extra] = url.pathname.split("/");
    if (seccion === "salas" && nombre) {
      const sala = env.SALAS.getByName(nombre);
      if (extra === "historial") return Response.json(await sala.historial());
      if (request.headers.get("Upgrade") !== "websocket") return new Response("Se esperaba un WebSocket", { status: 426 });
      return sala.fetch(request);
    }

    return new Response("No existe", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Lo esencial del limitador:

  • env.LIMITADOR.getByName(ip) devuelve un stub: un representante del objeto con ese nombre. Es la forma corta de idFromName() más get().
  • await limitador.consumir(5, 10_000) es una llamada RPC: los métodos públicos de la clase se llaman desde el Worker como funciones normales, con argumentos y valores de retorno tipados. Ya no hace falta inventarse un protocolo HTTP para hablar con el objeto.
  • this.ctx.storage.kv es la API clave-valor síncrona del almacenamiento SQLite: sin await, porque el almacenamiento está en la misma máquina.
  • La alarma (setAlarm y el método alarm()) despierta al objeto aunque nadie lo llame. Aquí borra el estado cuando la ventana caduca, para no acumular datos de IPs que no vuelven.

Con npx wrangler dev --port 8793:

$ for i in 1 2 3 4 5 6; do curl -s localhost:8793/api/datos -w " %{http_code}\n"; done
{"ok":true,"restantes":4} 200
{"ok":true,"restantes":3} 200
{"ok":true,"restantes":2} 200
{"ok":true,"restantes":1} 200
{"ok":true,"restantes":0} 200
{"error":"Demasiadas peticiones"} 429

$ curl -i localhost:8793/api/datos
HTTP/1.1 429 Too Many Requests
Retry-After: 10

$ sleep 11; curl localhost:8793/api/datos
{"ok":true,"restantes":4}

Alarmas

Cada objeto tiene una sola alarma programada a la vez (setAlarm sustituye a la anterior). Si necesitas varios eventos, guárdalos en el almacenamiento y programa la alarma para el más cercano. La ejecución está garantizada al menos una vez: si alarm() lanza una excepción, se reintenta con espera exponencial empezando por 2 segundos, hasta 6 reintentos. Un manejador de alarma puede durar hasta 15 minutos de tiempo de reloj.

Ejemplo 2: un chat con WebSockets que hiberna

Una sala de chat es un objeto por sala: todos los usuarios conectados a "general" tienen su WebSocket en el mismo objeto, que guarda el historial en su SQLite y reenvía cada mensaje a todos.

La clave está en cómo se acepta el WebSocket:

  • Con servidor.accept() (la API estándar), el objeto tiene que seguir en memoria mientras la conexión esté abierta, y pagas duración todo ese tiempo.
  • Con this.ctx.acceptWebSocket(servidor) (la API de hibernación), el runtime mantiene la conexión por ti. El objeto puede salir de memoria entre mensajes y, cuando llega uno, se reconstruye y recibe la llamada a webSocketMessage(). Pagas solo el rato que procesas mensajes.

Para que esto funcione, el estado de cada conexión (aquí el nombre del autor) se guarda en el propio WebSocket con serializeAttachment(), que sobrevive a la hibernación. Y setWebSocketAutoResponse contesta los ping con pong sin despertar al objeto: según la documentación, esas respuestas automáticas no suman duración.

Un cliente mínimo con el WebSocket global de Node 22+:

// node cliente.mjs <sala> <nombre> [mensajes...]  (Node 22+ trae WebSocket global)
const [sala, nombre, ...mensajes] = process.argv.slice(2);
const ws = new WebSocket(`ws://localhost:8793/salas/${sala}?nombre=${nombre}`);

ws.addEventListener("open", async () => {
  ws.send("ping"); // lo contesta el runtime sin despertar al objeto
  for (const m of mensajes) ws.send(m);
  setTimeout(() => ws.close(1000, "adiós"), 300);
});
ws.addEventListener("message", (e) => console.log(`[${nombre}] recibe:`, e.data));
ws.addEventListener("close", (e) => console.log(`[${nombre}] cerrado`, e.code, e.reason));

Ana entra, saluda y se va. Luego entra Luis y recibe el historial antes de escribir:

$ curl -s localhost:8793/salas/general -w " %{http_code}\n"
Se esperaba un WebSocket 426

$ node cliente.mjs general Ana "hola a todos" "¿hay alguien?"
[Ana] recibe: pong
[Ana] recibe: {"autor":"Ana","texto":"hola a todos","enviado_en":"2026-10-02T18:28:18.977Z"}
[Ana] recibe: {"autor":"Ana","texto":"¿hay alguien?","enviado_en":"2026-10-02T18:28:18.979Z"}
[Ana] cerrado 1000 adiós

$ node cliente.mjs general Luis "hola Ana"
[Luis] recibe: {"autor":"Ana","texto":"hola a todos","enviado_en":"2026-10-02T18:28:18.977Z"}
[Luis] recibe: {"autor":"Ana","texto":"¿hay alguien?","enviado_en":"2026-10-02T18:28:18.979Z"}
[Luis] recibe: pong
[Luis] recibe: {"autor":"Luis","texto":"hola Ana","enviado_en":"2026-10-02T18:28:19.368Z"}
[Luis] cerrado 1000 adiós

$ curl localhost:8793/salas/general/historial
[{"autor":"Ana","texto":"hola a todos","enviado_en":"2026-10-02T18:28:18.977Z"},
 {"autor":"Ana","texto":"¿hay alguien?","enviado_en":"2026-10-02T18:28:18.979Z"},
 {"autor":"Luis","texto":"hola Ana","enviado_en":"2026-10-02T18:28:19.368Z"}]

La última petición no usa WebSockets: el Worker llama al método historial() del objeto por RPC y devuelve el JSON.

🐛
Cerrar bien un WebSocket hibernable tiene truco. Mi primera versión de webSocketClose() devolvía el código de cierre del cliente tal cual (ws.close(code, reason)) y fallaba con InvalidAccessError cuando el cliente cerraba sin código (1005). Si no llamaba a close(), el cliente no recibía nunca su evento de cierre. La versión del artículo responde con 1000 cuando el código es 1005 o 1006, que no se pueden enviar.

El almacenamiento SQLite

this.ctx.storage.sql.exec(sql, ...parametros) ejecuta SQL contra la base de datos del objeto y devuelve un cursor con toArray(), one() (exactamente una fila, o error) y raw(). Las mismas reglas de SQLite que en D1, pero síncronas y locales. El constructor crea la tabla dentro de blockConcurrencyWhile(), que impide que entre ninguna petición hasta que termine: así nunca se procesa un mensaje antes de que exista la tabla.

Además de SQL, el mismo almacenamiento ofrece la API clave-valor ctx.storage.kv (la que usa el limitador) y la API asíncrona clásica ctx.storage.get/put, que sigue funcionando.

Pruebas

El plugin de Vitest trae utilidades específicas: runInDurableObject ejecuta código dentro de una instancia (con acceso a su state.storage) y runDurableObjectAlarm dispara la alarma pendiente al momento:

import { env, exports } from "cloudflare:workers";
import { runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { Limitador } from "../src/index";

describe("Limitador (Durable Object)", () => {
  it("deja pasar 5 y bloquea la sexta", async () => {
    const stub = env.LIMITADOR.getByName("1.2.3.4");
    for (let i = 0; i < 5; i++) expect((await stub.consumir(5, 10_000)).permitido).toBe(true);
    expect((await stub.consumir(5, 10_000)).permitido).toBe(false);
  });

  it("la alarma borra el estado", async () => {
    const stub = env.LIMITADOR.getByName("5.6.7.8");
    await stub.consumir(5, 10_000);

    // Mirar dentro del objeto: su almacenamiento real
    await runInDurableObject(stub, async (instancia: Limitador, state) => {
      expect(state.storage.kv.get<number[]>("marcas")).toHaveLength(1);
      expect(await state.storage.getAlarm()).not.toBeNull();
    });

    // Ejecutar la alarma ya, sin esperar 10 segundos
    expect(await runDurableObjectAlarm(stub)).toBe(true);
    await runInDurableObject(stub, (_i: Limitador, state) => {
      expect(state.storage.kv.get("marcas")).toBeUndefined();
    });
  });

  it("cada IP tiene su propio objeto", async () => {
    const a = await env.LIMITADOR.getByName("a").consumir(1, 10_000);
    const b = await env.LIMITADOR.getByName("b").consumir(1, 10_000);
    expect([a.permitido, b.permitido]).toEqual([true, true]);
  });
});

describe("Sala (WebSockets)", () => {
  it("guarda y difunde mensajes", async () => {
    const res = await exports.default.fetch("https://x/salas/test?nombre=Ana", { headers: { Upgrade: "websocket" } });
    expect(res.status).toBe(101);
    const ws = res.webSocket!;
    ws.accept();
    const llega = new Promise<string>((ok) => ws.addEventListener("message", (e) => ok(e.data as string)));
    ws.send("hola");
    expect(JSON.parse(await llega)).toMatchObject({ autor: "Ana", texto: "hola" });

    const historial = await env.SALAS.getByName("test").historial();
    expect(historial.map((m) => m.texto)).toEqual(["hola"]);
    // Esperar al cierre limpio para que el test no termine con la conexión a medias
    const cerrado = new Promise((ok) => ws.addEventListener("close", ok));
    ws.close(1000, "adiós");
    await cerrado;
  });
});
$ npx vitest run

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Duration  1.52s (transform 44ms, setup 0ms, import 68ms, tests 81ms, environment 1ms)

El test de la alarma es el más útil: comprueba que hay estado y una alarma programada, la dispara sin esperar 10 segundos y verifica que el estado ha desaparecido.

Límites que importan

LímiteValor
Número de objetosIlimitado
Clases por cuenta500 (Paid) / 100 (Free)
Almacenamiento por objeto (SQLite)10 GB
Almacenamiento por cuentaIlimitado (Paid) / 5 GB (Free)
Clave + valor (API clave-valor)2 MB
Mensaje WebSocket recibido32 MiB
CPU por petición30 s por defecto, configurable hasta 5 minutos
Peticiones por objetoLímite blando de unas 1.000 por segundo
Tiempo de reloj de una alarma15 minutos
SQLIgual que D1: 100 columnas, 2 MB por fila, 100 parámetros
💡
Elegir dónde vive un objeto. Se crea cerca de quien lo pide por primera vez. Si sabes de antemano dónde estarán sus usuarios, puedes pasar una location hint al obtenerlo (env.SALAS.get(id, { locationHint: "weur" })), y si los datos deben quedarse en la UE, crear los identificadores dentro de la jurisdicción eu (env.SALAS.jurisdiction("eu")). Esto no lo he probado: en local todo vive en tu máquina.

Errores comunes (y cómo evitarlos)

  • Un objeto global. Un único Durable Object para toda la aplicación es un cuello de botella de un hilo. Parte por usuario, sala o documento.
  • Guardar el estado solo en memoria. El objeto puede hibernar o reiniciarse en cualquier momento. Las propiedades de la clase son caché; la verdad está en ctx.storage.
  • accept() en lugar de acceptWebSocket(). Funciona igual y cuesta muchísimo más: pagas duración por cada segundo de conexión abierta.
  • Esperar cosas externas dentro de una sección crítica. Mientras haces await fetch(...), el objeto puede atender otros eventos. Lee, decide y escribe en el almacenamiento sin await externos en medio si necesitas atomicidad.
  • Cerrar mal los WebSockets. Devuelve un código válido en webSocketClose() (1000 si el cliente mandó 1005 o 1006).
  • Editar migraciones ya desplegadas. Añade una nueva etiqueta; nunca toques las anteriores.
  • Confiar en una sola alarma para muchas tareas. Solo hay una por objeto: guarda la agenda en el almacenamiento.

Cuándo usar Durable Objects (y cuándo no)

Úsalos cuando necesites coordinación: contadores exactos, límites de peticiones, colaboración en tiempo real, chats, partidas multijugador, colas de trabajo por cliente, sesiones con estado o cualquier cosa donde varias peticiones simultáneas modifiquen el mismo dato. También como "una base de datos SQLite por usuario", sin límite de número.

No los necesitas para datos de solo lectura (KV), consultas relacionales sobre todos tus datos (D1, porque no puedes hacer un SELECT que cruce objetos) ni para trabajo en segundo plano sin coordinación (Queues).

Sigue leyendo