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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cloudflare-durable-objects Installs to ~/.agents/skills/cloudflare-durable-objects/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cloudflare-durable-objects The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cloudflare-durable-objects Installs to .agents/skills/cloudflare-durable-objects/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cloudflare-durable-objects.zipunzip -o cloudflare-durable-objects.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cloudflare-durable-objects PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- Stateless request handling with no coordination: use cloudflare-workers
- Relational data queried across all users: use cloudflare-d1
- WebSockets on your own servers rather than Cloudflare: use websockets-realtime
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.
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
| Need | Durable 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 cache | No: Workers KV |
| Relational queries across all users | No: D1 (or DO per tenant + D1 for cross-tenant reporting) |
| One object handling all global traffic | No: 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_classespermanently deletes every object and its data for that class. Get explicit user confirmation and an export plan first.renamed_classeskeeps 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
awaiton 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 /putsequences safe without explicit locks, as long as no externalfetchsits between them. blockConcurrencyWhileblocks 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;
setAlarmreplaces 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
setTimeoutfor 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, notserver.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 firstgetmatters).env.ROOMS.jurisdiction("eu").idFromName(key)(ornewUniqueId({ 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
.retryableand.overloaded. Retry only whenretryableis true andoverloadedis 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.execare materialised bytoArray(); iterate the cursor or paginate withLIMIT. - Local
wrangler devpersists DO state in.wrangler/state; clear it to test migrations from scratch.
Verification
- Vitest with
@cloudflare/vitest-pool-workers: userunInDurableObject(stub, (instance, state) => ...)to assert storage contents andrunDurableObjectAlarm(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 tailshows noDurable Object resetor 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.yaml
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.