Cloudflare Durable Objects skill for Codex

One object per room, state that survives eviction. Codex shards Durable Objects by a natural key so no single object becomes a global bottleneck, keeps state in SQLite storage instead of instance fields, and relies on input and output gates rather than ad-hoc locks. WebSocket rooms use the Hibernation API with serialised attachments, alarms are idempotent, and destructive class migrations wait for explicit confirmation.

  • Cloud Platforms
  • $cloudflare-durable-objects
  • 222 lines
  • 2 files
  • ~2,400 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cloudflare-durable-objects

Installs to ~/.agents/skills/cloudflare-durable-objects/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $cloudflare-durable-objects, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds stateful coordination on Cloudflare Durable Objects - DurableObject classes with SQLite-backed storage and the sql.exec API, RPC methods on stubs, idFromName sharding, blockConcurrencyWhile for schema setup, input and output gates, alarms for scheduled work, the WebSocket Hibernation API for chat and multiplayer rooms, migrations in Wrangler config and location or jurisdiction hints. Use when building rate limiters, counters, chat rooms, collaborative documents, per-user or per-tenant state, WebSocket servers on Cloudflare, or debugging Durable Object overload, eviction and migration errors.

Use it when

  • Per-key state needs strong consistency on Cloudflare
  • Building WebSocket rooms, presence or collaborative editing
  • Rate limiting, counters or per-entity job serialisation
  • Durable Object overload, reset or migration errors

Use something else when

How to check the result

Vitest runInDurableObject assertions pass for storage and alarms, a single-key load test meets the target rate, and wrangler tail shows no overload or reset errors.

The mistake it prevents

Routing every request to one idFromName constant, turning a single object into a global bottleneck.

~/.agents/skills/cloudflare-durable-objects/SKILL.md

--- name: cloudflare-durable-objects description: Builds stateful coordination on Cloudflare Durable Objects - DurableObject classes with SQLite-backed storage and the sql.exec API, RPC methods on… license: MIT ---

Done well means: each object owns one natural unit of state (a room, a user, a document, a tenant) so no single object is a global hotspot; state lives in SQLite storage, not in instance fields; WebSockets use the Hibernation API; scheduled work uses alarms that are safe to run twice; and class migrations are declared before any rename or delete.

When to use one

NeedDurable Objects?
Strongly consistent state per key with many writes (counters, rate limits, inventory)Yes
Many clients coordinating in real time (chat, cursors, game rooms)Yes, with WebSocket Hibernation
Serialising work per entity (one job at a time per account)Yes
Read-mostly global config or cacheNo: Workers KV
Relational queries across all usersNo: D1 (or DO per tenant + D1 for cross-tenant reporting)
One object handling all global trafficNo: shard by key; a single object handles on the order of 1,000 simple requests per second

Inspect first

Read AGENTS.md, the Wrangler config (durable_objects.bindings, migrations) and the class files. Every class change that renames, deletes or changes storage backend needs a new migration entry; check which tags are already deployed:

npx wrangler deployments list
grep -n '"tag"' wrangler.jsonc

Config

{
  "durable_objects": {
    "bindings": [
      { "name": "ROOMS", "class_name": "ChatRoom" },
      { "name": "RATE_LIMITER", "class_name": "RateLimiter" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] },
    { "tag": "v2", "new_sqlite_classes": ["RateLimiter"] }
  ]
}
  • New classes should use new_sqlite_classes (SQLite storage backend). It is required on the Free plan and unlocks the SQL API. A class created with the legacy key-value backend cannot be switched in place.
  • Migration tags are append-only. Never edit or remove an applied entry.
  • deleted_classes permanently deletes every object and its data for that class. Get explicit user confirmation and an export plan first. renamed_classes keeps data.
  • Export the class from the Worker’s main module (export { ChatRoom } from "./room"), or deploy fails with a missing class error.

Class skeleton with SQLite and RPC

import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // Runs before any request is delivered; keep it short.
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS hits (
          window_start INTEGER PRIMARY KEY,
          count INTEGER NOT NULL
        )`);
    });
  }

  async hit(limit: number, windowMs: number): Promise<{ allowed: boolean; count: number }> {
    const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
    const row = this.ctx.storage.sql
      .exec<{ count: number }>(
        `INSERT INTO hits (window_start, count) VALUES (?, 1)
         ON CONFLICT(window_start) DO UPDATE SET count = count + 1
         RETURNING count`,
        windowStart,
      )
      .one();
    this.ctx.storage.sql.exec("DELETE FROM hits WHERE window_start < ?", windowStart - windowMs);
    return { allowed: row.count <= limit, count: row.count };
  }
}

Calling it from a Worker:

export default {
  async fetch(req, env): Promise<Response> {
    const ip = req.headers.get("cf-connecting-ip") ?? "unknown";
    const stub = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName(`ip:${ip}`));
    const { allowed } = await stub.hit(100, 60_000);          // RPC: typed method call
    return allowed ? fetch(req) : new Response("slow down", { status: 429 });
  },
} satisfies ExportedHandler<Env>;
  • idFromName(key) maps the same key to the same object worldwide; this is the sharding decision. Choose keys that spread load (per user, per room), not one constant.
  • newUniqueId() creates a fresh object; store the ID string (id.toString()) to reach it again.
  • RPC requires a compatibility date of 2024-04-03 or later. Prefer RPC over stub.fetch() for internal calls; it is typed and avoids Request/Response plumbing.

Concurrency model

  • One object processes events one at a time per JavaScript turn, but await on I/O other than storage (e.g. fetch) lets other requests interleave. Re-read state after such awaits.
  • Storage operations are protected by input/output gates: while a storage write is pending, new events are not delivered and outgoing responses wait until the write is durable. This makes get / modify / put sequences safe without explicit locks, as long as no external fetch sits between them.
  • blockConcurrencyWhile blocks all events; use it for initialisation, not per request.
  • Instance fields are a cache only. The object can be evicted at any time when idle, and restarts on deploy; anything that must survive goes to ctx.storage.

Alarms

async scheduleFlush() {
  if ((await this.ctx.storage.getAlarm()) === null) {
    await this.ctx.storage.setAlarm(Date.now() + 30_000);
  }
}

async alarm(info?: AlarmInvocationInfo) {
  const pending = this.ctx.storage.sql.exec("SELECT id, payload FROM outbox ORDER BY id LIMIT 100").toArray();
  for (const row of pending) {
    await deliver(row);                                         // must be idempotent
    this.ctx.storage.sql.exec("DELETE FROM outbox WHERE id = ?", row.id);
  }
  if (pending.length === 100) await this.ctx.storage.setAlarm(Date.now() + 1_000);
}
  • One alarm per object; setAlarm replaces the existing one.
  • Alarms are at-least-once and retried with backoff on exceptions (info.retryCount). Make the handler idempotent and record progress in storage as you go.
  • Use alarms instead of setTimeout for anything longer than the current request.

WebSocket Hibernation

export class ChatRoom extends DurableObject<Env> {
  // constructor creates the messages table with blockConcurrencyWhile, as in RateLimiter
  async fetch(req: Request): Promise<Response> {
    if (req.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
    const user = new URL(req.url).searchParams.get("user") ?? "anon";   // authenticate in the Worker first
    const { 0: client, 1: server } = new WebSocketPair();
    this.ctx.acceptWebSocket(server, [user]);                  // tags for getWebSockets(tag)
    server.serializeAttachment({ user, joinedAt: Date.now() }); // survives hibernation
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    const { user } = ws.deserializeAttachment() as { user: string };
    const text = typeof message === "string" ? message : new TextDecoder().decode(message);
    this.ctx.storage.sql.exec("INSERT INTO messages (user, text, at) VALUES (?, ?, ?)", user, text, Date.now());
    for (const peer of this.ctx.getWebSockets()) peer.send(JSON.stringify({ user, text }));
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string) {
    ws.close(code, reason);
  }
}
  • Use ctx.acceptWebSocket, not server.accept(): hibernation lets the object sleep with sockets open, so idle rooms cost nothing for duration.
  • After hibernation the constructor runs again and in-memory fields are empty; per-socket data must come from serializeAttachment (small, a few KB) or storage.
  • ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")) answers keepalives without waking the object.
  • Authenticate in the Worker before forwarding the upgrade to the object; pass identity in a header or URL the object trusts only from the Worker.

Placement and data residency

  • env.ROOMS.get(id, { locationHint: "weur" }) hints where a new object is created (only the first get matters).
  • env.ROOMS.jurisdiction("eu").idFromName(key) (or newUniqueId({ jurisdiction: "eu" })) keeps the object and its storage inside the EU. It cannot be changed later for that ID.

Gotchas

  • Errors thrown from a stub call may carry .retryable and .overloaded. Retry only when retryable is true and overloaded is not, with backoff and a small limit.
  • Do not hold a stub across requests in global scope expecting it to stay healthy; create stubs per request (cheap).
  • ctx.storage.deleteAll() wipes the object’s data; confirm before using it outside tests.
  • Large result sets from sql.exec are materialised by toArray(); iterate the cursor or paginate with LIMIT.
  • Local wrangler dev persists DO state in .wrangler/state; clear it to test migrations from scratch.

Verification

  • Vitest with @cloudflare/vitest-pool-workers: use runInDurableObject(stub, (instance, state) => ...) to assert storage contents and runDurableObjectAlarm(stub) to fire alarms deterministically.
  • Load test a single key to confirm per-object throughput meets the need; if not, shard the key.
  • After deploy, npx wrangler tail shows no Durable Object reset or overload errors under traffic.

Report template

**Classes:** ChatRoom (SQLite, migration v1), RateLimiter (SQLite, migration v2)
**Sharding:** ChatRoom by room id; RateLimiter by ip:<addr>
**Storage:** messages table (room), hits table (window counters), cleanup in-request
**Realtime:** Hibernation API, tags per user, ping/pong auto-response
**Alarms:** outbox flush every 30 s, idempotent delivery keyed on message id
**Tests:** runInDurableObject storage asserts, runDurableObjectAlarm flush test

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Cloudflare Durable Objects"
  short_description: "One object per room, state that survives eviction"
  default_prompt: "$cloudflare-durable-objects Build a WebSocket chat room on Cloudflare Durable Objects that scales to idle rooms cheaply"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem