|
| 1 | +# Durable Objects |
| 2 | + |
| 3 | +Durable Objects give your worker persistent state and real-time coordination — think chat rooms, game sessions, collaborative editing, or any stateful backend that needs to survive between requests. |
| 4 | + |
| 5 | +Each Durable Object instance has its own SQLite database and runs in a single location, so you get strong consistency without managing infrastructure. |
| 6 | + |
| 7 | +## Quick Start |
| 8 | + |
| 9 | +Add a Durable Object to your `wrangler.jsonc`: |
| 10 | + |
| 11 | +```jsonc |
| 12 | +{ |
| 13 | + "compatibility_flags": ["nodejs_compat"], |
| 14 | + "durable_objects": { |
| 15 | + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] |
| 16 | + }, |
| 17 | + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }] |
| 18 | +} |
| 19 | +``` |
| 20 | + |
| 21 | +Create and export your class: |
| 22 | + |
| 23 | +```typescript |
| 24 | +// src/counter.ts |
| 25 | +import { DurableObject } from "cloudflare:workers"; |
| 26 | + |
| 27 | +export class Counter extends DurableObject<Env> { |
| 28 | + async fetch(request: Request): Promise<Response> { |
| 29 | + const count = (this.ctx.storage.sql |
| 30 | + .exec("SELECT count FROM hits LIMIT 1") |
| 31 | + .one()?.count as number) ?? 0; |
| 32 | + |
| 33 | + this.ctx.storage.sql.exec( |
| 34 | + "CREATE TABLE IF NOT EXISTS hits (count INTEGER)" |
| 35 | + ); |
| 36 | + this.ctx.storage.sql.exec( |
| 37 | + "INSERT OR REPLACE INTO hits (rowid, count) VALUES (1, ?)", |
| 38 | + count + 1 |
| 39 | + ); |
| 40 | + |
| 41 | + return Response.json({ count: count + 1 }); |
| 42 | + } |
| 43 | +} |
| 44 | +``` |
| 45 | + |
| 46 | +Re-export it from your entrypoint: |
| 47 | + |
| 48 | +```typescript |
| 49 | +// src/index.ts |
| 50 | +export { Counter } from "./counter"; |
| 51 | + |
| 52 | +interface Env { |
| 53 | + COUNTER: DurableObjectNamespace; |
| 54 | +} |
| 55 | + |
| 56 | +export default { |
| 57 | + async fetch(request: Request, env: Env) { |
| 58 | + const id = env.COUNTER.idFromName("global"); |
| 59 | + const stub = env.COUNTER.get(id); |
| 60 | + return stub.fetch(request); |
| 61 | + }, |
| 62 | +}; |
| 63 | +``` |
| 64 | + |
| 65 | +Deploy: |
| 66 | + |
| 67 | +```bash |
| 68 | +jack ship |
| 69 | +``` |
| 70 | + |
| 71 | +## Configuration |
| 72 | + |
| 73 | +### Bindings |
| 74 | + |
| 75 | +Each Durable Object class needs a binding in `wrangler.jsonc`: |
| 76 | + |
| 77 | +```jsonc |
| 78 | +{ |
| 79 | + "durable_objects": { |
| 80 | + "bindings": [ |
| 81 | + { "name": "COUNTER", "class_name": "Counter" }, |
| 82 | + { "name": "ROOM", "class_name": "ChatRoom" } |
| 83 | + ] |
| 84 | + } |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +- `name` — the variable name in your `Env` type |
| 89 | +- `class_name` — must match the exported class name exactly |
| 90 | + |
| 91 | +### Migrations |
| 92 | + |
| 93 | +Durable Objects with SQLite storage need a migration entry: |
| 94 | + |
| 95 | +```jsonc |
| 96 | +{ |
| 97 | + "migrations": [ |
| 98 | + { "tag": "v1", "new_sqlite_classes": ["Counter", "ChatRoom"] } |
| 99 | + ] |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +When you add a new class later, add a new migration step: |
| 104 | + |
| 105 | +```jsonc |
| 106 | +{ |
| 107 | + "migrations": [ |
| 108 | + { "tag": "v1", "new_sqlite_classes": ["Counter", "ChatRoom"] }, |
| 109 | + { "tag": "v2", "new_sqlite_classes": ["GameSession"] } |
| 110 | + ] |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +Don't edit existing migration entries — always append. |
| 115 | + |
| 116 | +### Compatibility Flags |
| 117 | + |
| 118 | +Durable Objects require the `nodejs_compat` flag: |
| 119 | + |
| 120 | +```jsonc |
| 121 | +{ |
| 122 | + "compatibility_flags": ["nodejs_compat"] |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +Jack auto-adds this if you forget, but it's better to be explicit. |
| 127 | + |
| 128 | +## Usage Patterns |
| 129 | + |
| 130 | +### Named instances |
| 131 | + |
| 132 | +Use `idFromName()` when you want a predictable, shared instance: |
| 133 | + |
| 134 | +```typescript |
| 135 | +// Everyone hitting /room/lobby gets the same DO |
| 136 | +const id = env.ROOM.idFromName("lobby"); |
| 137 | +const room = env.ROOM.get(id); |
| 138 | +``` |
| 139 | + |
| 140 | +### Unique instances |
| 141 | + |
| 142 | +Use `newUniqueId()` for per-session or per-user state: |
| 143 | + |
| 144 | +```typescript |
| 145 | +const id = env.SESSION.newUniqueId(); |
| 146 | +const session = env.SESSION.get(id); |
| 147 | + |
| 148 | +// Store the ID somewhere so you can reconnect |
| 149 | +const sessionId = id.toString(); |
| 150 | +``` |
| 151 | + |
| 152 | +### SQLite storage |
| 153 | + |
| 154 | +Every DO instance gets its own SQLite database via `this.ctx.storage.sql`: |
| 155 | + |
| 156 | +```typescript |
| 157 | +export class Notes extends DurableObject<Env> { |
| 158 | + async fetch(request: Request): Promise<Response> { |
| 159 | + if (request.method === "POST") { |
| 160 | + const { title, body } = await request.json(); |
| 161 | + this.ctx.storage.sql.exec( |
| 162 | + "CREATE TABLE IF NOT EXISTS notes (title TEXT, body TEXT, created_at TEXT)" |
| 163 | + ); |
| 164 | + this.ctx.storage.sql.exec( |
| 165 | + "INSERT INTO notes (title, body, created_at) VALUES (?, ?, datetime('now'))", |
| 166 | + title, body |
| 167 | + ); |
| 168 | + return Response.json({ ok: true }); |
| 169 | + } |
| 170 | + |
| 171 | + const notes = this.ctx.storage.sql |
| 172 | + .exec("SELECT * FROM notes ORDER BY created_at DESC") |
| 173 | + .toArray(); |
| 174 | + return Response.json(notes); |
| 175 | + } |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +### WebSockets |
| 180 | + |
| 181 | +Durable Objects are the standard way to handle WebSockets: |
| 182 | + |
| 183 | +```typescript |
| 184 | +export class ChatRoom extends DurableObject<Env> { |
| 185 | + async fetch(request: Request): Promise<Response> { |
| 186 | + if (request.headers.get("Upgrade") === "websocket") { |
| 187 | + const [client, server] = Object.values(new WebSocketPair()); |
| 188 | + this.ctx.acceptWebSocket(server); |
| 189 | + return new Response(null, { status: 101, webSocket: client }); |
| 190 | + } |
| 191 | + return new Response("Expected WebSocket", { status: 400 }); |
| 192 | + } |
| 193 | + |
| 194 | + async webSocketMessage(ws: WebSocket, message: string) { |
| 195 | + // Broadcast to all connected clients |
| 196 | + for (const client of this.ctx.getWebSockets()) { |
| 197 | + if (client !== ws) { |
| 198 | + client.send(message); |
| 199 | + } |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + async webSocketClose(ws: WebSocket) { |
| 204 | + ws.close(); |
| 205 | + } |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +## Agents SDK |
| 210 | + |
| 211 | +For AI chat applications, the [Agents SDK](https://developers.cloudflare.com/agents/) provides `AIChatAgent` — a higher-level abstraction built on Durable Objects that handles message history, real-time sync, and streaming out of the box. |
| 212 | + |
| 213 | +The **ai-chat** template uses this pattern: |
| 214 | + |
| 215 | +```bash |
| 216 | +jack new my-chat -t ai-chat |
| 217 | +``` |
| 218 | + |
| 219 | +```typescript |
| 220 | +import { AIChatAgent } from "@cloudflare/ai-chat"; |
| 221 | +import { streamText } from "ai"; |
| 222 | + |
| 223 | +export class Chat extends AIChatAgent<Env> { |
| 224 | + async onChatMessage(onFinish) { |
| 225 | + const result = streamText({ |
| 226 | + model: provider("@cf/meta/llama-3.3-70b-instruct-fp8-fast"), |
| 227 | + messages: await convertToModelMessages(this.messages), |
| 228 | + onFinish, |
| 229 | + }); |
| 230 | + return result.toUIMessageStreamResponse(); |
| 231 | + } |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +Rooms, persistence, and WebSocket sync are all handled automatically. |
| 236 | + |
| 237 | +## What Jack Does For You |
| 238 | + |
| 239 | +When you run `jack ship` with Durable Objects configured: |
| 240 | + |
| 241 | +1. **Auto-fixes prerequisites** — adds `nodejs_compat` flag and migration entries if missing |
| 242 | +2. **Validates exports** — checks your built code actually exports the declared classes (catches typos before deploy) |
| 243 | +3. **Handles deployment** — configures dispatch hints and DO bindings on the Cloudflare side |
| 244 | +4. **Tracks resources** — registers your DOs so they show up in `jack info` |
| 245 | + |
| 246 | +You don't need a Cloudflare account or API tokens — jack cloud manages everything. |
| 247 | + |
| 248 | +## Local Development |
| 249 | + |
| 250 | +For local development with `wrangler dev`, Durable Objects work out of the box — no extra setup needed. State is stored in `.wrangler/state/`. |
| 251 | + |
| 252 | +```bash |
| 253 | +wrangler dev |
| 254 | +``` |
| 255 | + |
| 256 | +## Resources |
| 257 | + |
| 258 | +- [Cloudflare Durable Objects Docs](https://developers.cloudflare.com/durable-objects/) |
| 259 | +- [Agents SDK](https://developers.cloudflare.com/agents/) |
| 260 | +- [SQLite in Durable Objects](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/) |
0 commit comments