> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Durable Objects a fondo: estado con coordinación, WebSockets y SQLite en cada objeto
- URL: https://blog.devutilites.com/cloudflare-durable-objects-a-fondo/
- Published: 2026-10-03T03:13:56.000Z
- Updated: 2026-10-03T03:13:56.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Cloudflare, Backend

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](https://blog.devutilites.com/content/images/2026/10/cf-do-arquitectura.png)

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 Free       | Workers Paid                                          |
| --------------------------------------------------- | ------------------ | ----------------------------------------------------- |
| Peticiones (HTTP, RPC, mensajes WebSocket, alarmas) | 100.000 al día     | 1 millón al mes, luego 0,15 $ por millón              |
| Duración                                            | 13.000 GB-s al día | 400.000 GB-s al mes, luego 12,50 $ por millón de GB-s |
| Filas leídas (SQLite)                               | 5 millones al día  | 25.000 millones al mes, luego 0,001 $ por millón      |
| Filas escritas (SQLite)                             | 100.000 al día     | 50 millones al mes, luego 1,00 $ por millón           |
| Almacenamiento SQLite                               | 5 GB en total      | 5 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.

```json
{
  "$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):

```typescript
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`:

```bash
$ 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+:

```javascript
// 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:

```text
$ 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:

```typescript
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;
  });
});
```

```text
$ 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ímite                             | Valor                                                     |
| ---------------------------------- | --------------------------------------------------------- |
| Número de objetos                  | Ilimitado                                                 |
| Clases por cuenta                  | 500 (Paid) / 100 (Free)                                   |
| Almacenamiento por objeto (SQLite) | 10 GB                                                     |
| Almacenamiento por cuenta          | Ilimitado (Paid) / 5 GB (Free)                            |
| Clave + valor (API clave-valor)    | 2 MB                                                      |
| Mensaje WebSocket recibido         | 32 MiB                                                    |
| CPU por petición                   | 30 s por defecto, configurable hasta 5 minutos            |
| Peticiones por objeto              | Límite blando de unas 1.000 por segundo                   |
| Tiempo de reloj de una alarma      | 15 minutos                                                |
| SQL                                | Igual 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

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): dónde encaja cada servicio.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): el runtime y el modelo de bindings.
- [Cloudflare Workers KV a fondo](https://blog.devutilites.com/cloudflare-workers-kv-a-fondo/): por qué KV no sirve para contadores.
- [Cloudflare D1 a fondo](https://blog.devutilites.com/cloudflare-d1-a-fondo/): el mismo SQLite, como base de datos compartida.
- [Planes de Cloudflare Workers: Free frente a Paid](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/): duración, GB-s y cómo no llevarse sustos.
- [Cloudflare Workflows a fondo](https://blog.devutilites.com/cloudflare-workflows-a-fondo/): procesos de varios pasos con estado, construidos sobre la misma base.
- [Cloudflare Containers a fondo](https://blog.devutilites.com/cloudflare-containers-a-fondo/): cada contenedor es un Durable Object.