| name | cloudflare-durable-objects |
| description | Build stateful Durable Objects for real-time apps, WebSocket servers, coordination, and persistent state. Use when: implementing chat rooms, multiplayer games, rate limiting, session management, WebSocket hibernation, or troubleshooting class export, migration, WebSocket state loss, or binding errors.
|
| license | MIT |
Cloudflare Durable Objects
Status: Production Ready ✅
Last Updated: 2025-11-23
Dependencies: cloudflare-worker-base (recommended)
Latest Versions: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0
Official Docs: https://developers.cloudflare.com/durable-objects/
Recent Updates (2025):
- Oct 2025: WebSocket message size 1 MiB → 32 MiB, Data Studio UI for SQLite DOs (view/edit storage in dashboard)
- Aug 2025:
getByName() API shortcut for named DOs
- June 2025: @cloudflare/actors library (beta) - recommended SDK with migrations, alarms, Actor class pattern
- May 2025: Python Workers support for Durable Objects
- April 2025: SQLite GA with 10GB storage (beta → GA, 1GB → 10GB), Free tier access
- Feb 2025: PRAGMA optimize support, improved error diagnostics with reference IDs
Quick Start
Scaffold new DO project:
npm create cloudflare@latest my-durable-app -- --template=cloudflare/durable-objects-template --ts
Or add to existing Worker:
import { DurableObject } from 'cloudflare:workers';
export class Counter extends DurableObject {
async increment(): Promise<number> {
let value = (await this.ctx.storage.get<number>('value')) || 0;
await this.ctx.storage.put('value', ++value);
return value;
}
}
export default Counter;
{
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] }
]
}
import { Counter } from './counter';
export { Counter };
export default {
async fetch(request: Request, env: { COUNTER: DurableObjectNamespace<Counter> }) {
const stub = env.COUNTER.getByName('global-counter');
return new Response(`Count: ${await stub.increment()}`);
}
};
DO Class Essentials
import { DurableObject } from 'cloudflare:workers';
export class MyDO extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.value = await ctx.storage.get('key') || defaultValue;
});
}
async myMethod(): Promise<string> { return 'Hello'; }
async fetch(request: Request): Promise<Response> { return new Response('OK'); }
}
export default MyDO;
import { MyDO } from './my-do';
export { MyDO };
Constructor Rules:
- ✅ Call
super(ctx, env) first
- ✅ Keep minimal - heavy work blocks hibernation wake
- ✅ Use
ctx.blockConcurrencyWhile() for storage initialization
- ❌ Never
setTimeout/setInterval (use alarms)
- ❌ Don't rely on in-memory state with WebSockets (persist to storage)
Storage API
Two backends available:
- SQLite (recommended): 10GB storage, SQL queries, atomic operations, PITR
- KV: 128MB storage, key-value only
Enable SQLite in migrations:
{ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }] }
SQL API (SQLite backend)
export class MyDO extends DurableObject {
sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
this.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, text TEXT, created_at INTEGER);
CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at);
PRAGMA optimize; // Feb 2025: Query performance optimization
`);
}
async addMessage(text: string): Promise<number> {
const cursor = this.sql.exec('INSERT INTO messages (text, created_at) VALUES (?, ?) RETURNING id', text, Date.now());
return cursor.one<{ id: number }>().id;
}
async getMessages(limit = 50): Promise<any[]> {
return this.sql.exec('SELECT * FROM messages ORDER BY created_at DESC LIMIT ?', limit).toArray();
}
}
SQL Methods:
sql.exec(query, ...params) → cursor
cursor.one<T>() → single row (throws if none)
cursor.one<T>({ allowNone: true }) → row or null
cursor.toArray<T>() → all rows
ctx.storage.transactionSync(() => { ... }) → atomic multi-statement
Rules: Always use ? placeholders, create indexes, use PRAGMA optimize after schema changes
Key-Value API (both backends)
await this.ctx.storage.put('key', value);
const value = await this.ctx.storage.get<T>('key');
await this.ctx.storage.delete('key');
await this.ctx.storage.put({ key1: val1, key2: val2 });
const map = await this.ctx.storage.get(['key1', 'key2']);
await this.ctx.storage.delete(['key1', 'key2']);
const map = await this.ctx.storage.list({ prefix: 'user:', limit: 100 });
await this.ctx.storage.deleteAll();
await this.ctx.storage.transaction(async (txn) => {
await txn.put('key1', val1);
await txn.put('key2', val2);
});
Storage Limits: SQLite 10GB (April 2025 GA) | KV 128MB
WebSocket Hibernation API
Capabilities:
- Thousands of WebSocket connections per instance
- Hibernate when idle (~10s no activity) to save costs
- Auto wake-up when messages arrive
- Message size limit: 32 MiB (Oct 2025, up from 1 MiB)
How it works:
- Active → handles messages
- Idle → ~10s no activity
- Hibernation → in-memory state cleared, WebSockets stay connected
- Wake → message arrives → constructor runs → handler called
CRITICAL: In-memory state is lost on hibernation. Use serializeAttachment() to persist per-WebSocket metadata.
Hibernation-Safe Pattern
export class ChatRoom extends DurableObject {
sessions: Map<WebSocket, { userId: string; username: string }>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sessions = new Map();
ctx.getWebSockets().forEach((ws) => {
this.sessions.set(ws, ws.deserializeAttachment());
});
}
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
const url = new URL(request.url);
const metadata = { userId: url.searchParams.get('userId'), username: url.searchParams.get('username') };
this.ctx.acceptWebSocket(server);
server.serializeAttachment(metadata);
this.sessions.set(server, metadata);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const session = this.sessions.get(ws);
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
this.sessions.delete(ws);
ws.close(code, 'Closing');
}
async webSocketError(ws: WebSocket, error: any): Promise<void> {
this.sessions.delete(ws);
}
}
Hibernation Rules:
- ✅
ctx.acceptWebSocket(ws) - enables hibernation
- ✅
ws.serializeAttachment(data) - persist metadata
- ✅
ctx.getWebSockets().forEach() - restore in constructor
- ✅ Use alarms instead of
setTimeout/setInterval
- ❌
ws.accept() - standard API, no hibernation
- ❌
setTimeout/setInterval - prevents hibernation
- ❌ In-progress
fetch() - blocks hibernation
Alarms API
Schedule DO to wake at future time. Use for: batching, cleanup, reminders, periodic tasks.
export class Batcher extends DurableObject {
async addItem(item: string): Promise<void> {
const buffer = await this.ctx.storage.get<string[]>('buffer') || [];
buffer.push(item);
await this.ctx.storage.put('buffer', buffer);
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + 10000);
}
}
async alarm(info: { retryCount: number; isRetry: boolean }): Promise<void> {
if (info.retryCount > 3) return;
const buffer = await this.ctx.storage.get<string[]>('buffer') || [];
await this.processBatch(buffer);
await this.ctx.storage.put('buffer', []);
}
}
API Methods:
await ctx.storage.setAlarm(Date.now() + 60000) - set alarm (overwrites existing)
await ctx.storage.getAlarm() - get timestamp or null
await ctx.storage.deleteAlarm() - cancel alarm
async alarm(info) - handler called when alarm fires
Behavior:
- ✅ At-least-once execution, auto-retries (up to 6x, exponential backoff)
- ✅ Survives hibernation/eviction
- ✅ Auto-deleted after success
- ⚠️ One alarm per DO (new alarm overwrites)
RPC vs HTTP Fetch
RPC (Recommended): Direct method calls, type-safe, simple
export class Counter extends DurableObject {
async increment(): Promise<number> {
let value = (await this.ctx.storage.get<number>('count')) || 0;
await this.ctx.storage.put('count', ++value);
return value;
}
}
const stub = env.COUNTER.getByName('my-counter');
const count = await stub.increment();
HTTP Fetch: Request/response pattern, required for WebSocket upgrades
export class Counter extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/increment') {
let value = (await this.ctx.storage.get<number>('count')) || 0;
await this.ctx.storage.put('count', ++value);
return new Response(JSON.stringify({ count: value }));
}
return new Response('Not found', { status: 404 });
}
}
const stub = env.COUNTER.getByName('my-counter');
const response = await stub.fetch('https://fake-host/increment', { method: 'POST' });
const data = await response.json();
When to use: RPC for new projects (simpler), HTTP Fetch for WebSocket upgrades or complex routing
Getting DO Stubs
Three ways to get IDs:
idFromName(name) - Consistent routing (same name = same DO)
const stub = env.CHAT_ROOM.getByName('room-123');
newUniqueId() - Random unique ID (must store for reuse)
const id = env.MY_DO.newUniqueId({ jurisdiction: 'eu' });
const idString = id.toString();
idFromString(idString) - Recreate from saved ID
const id = env.MY_DO.idFromString(await env.KV.get('session:123'));
const stub = env.MY_DO.get(id);
Location hints (best-effort):
const stub = env.MY_DO.get(id, { locationHint: 'enam' });
Jurisdiction (strict enforcement):
const id = env.MY_DO.newUniqueId({ jurisdiction: 'eu' });
Migrations
Required for: create, rename, delete, transfer DO classes
1. Create:
{ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }] }
2. Rename:
{ "migrations": [
{ "tag": "v1", "new_sqlite_classes": ["OldName"] },
{ "tag": "v2", "renamed_classes": [{ "from": "OldName", "to": "NewName" }] }
]}
3. Delete:
{ "migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] },
{ "tag": "v2", "deleted_classes": ["Counter"] }
]}
4. Transfer:
{ "migrations": [{ "tag": "v1", "transferred_classes": [
{ "from": "OldClass", "from_script": "old-worker", "to": "NewClass" }
]}]}
Migration Rules:
- ❌ Atomic (all instances migrate at once, no gradual rollout)
- ❌ Tags are unique and append-only
- ❌ Cannot enable SQLite on existing KV-backed DOs
- ✅ Code changes don't need migrations (only schema changes)
- ✅ Class names globally unique per account
Common Patterns
Rate Limiting:
async checkLimit(userId: string, limit: number, window: number): Promise<boolean> {
const requests = (await this.ctx.storage.get<number[]>(`rate:${userId}`)) || [];
const valid = requests.filter(t => Date.now() - t < window);
if (valid.length >= limit) return false;
valid.push(Date.now());
await this.ctx.storage.put(`rate:${userId}`, valid);
return true;
}
Session Management with TTL:
async set(key: string, value: any, ttl?: number): Promise<void> {
const expiresAt = ttl ? Date.now() + ttl : null;
this.sql.exec('INSERT OR REPLACE INTO session (key, value, expires_at) VALUES (?, ?, ?)',
key, JSON.stringify(value), expiresAt);
}
async alarm(): Promise<void> {
this.sql.exec('DELETE FROM session WHERE expires_at < ?', Date.now());
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}
Leader Election:
async electLeader(workerId: string): Promise<boolean> {
try {
this.sql.exec('INSERT INTO leader (id, worker_id, elected_at) VALUES (1, ?, ?)', workerId, Date.now());
return true;
} catch { return false; }
}
Multi-DO Coordination:
const gameRoom = env.GAME_ROOM.getByName(gameId);
await gameRoom.initialize();
await this.ctx.storage.put(`game:${gameId}`, { created: Date.now() });
Critical Rules
Always Do
✅ Export DO class from Worker
export class MyDO extends DurableObject { }
export default MyDO;
✅ Call super(ctx, env) in constructor
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
✅ Use new_sqlite_classes for new DOs
{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }
✅ Use ctx.acceptWebSocket() for hibernation
this.ctx.acceptWebSocket(server);
✅ Persist critical state to storage (not just memory)
await this.ctx.storage.put('important', value);
✅ Use alarms instead of setTimeout/setInterval
await this.ctx.storage.setAlarm(Date.now() + 60000);
✅ Use parameterized SQL queries
this.sql.exec('SELECT * FROM table WHERE id = ?', id);
✅ Minimize constructor work
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
});
}
Never Do
❌ Create DO without migration
❌ Forget to export DO class
class MyDO extends DurableObject { }
❌ Use setTimeout or setInterval
setTimeout(() => {}, 1000);
❌ Rely only on in-memory state with WebSockets
❌ Deploy migrations gradually
❌ Enable SQLite on existing KV-backed DO
❌ Use standard WebSocket API expecting hibernation
ws.accept();
this.ctx.acceptWebSocket(ws);
❌ Assume location hints are guaranteed
Known Issues Prevention
This skill prevents 15+ documented issues:
Issue #1: Class Not Exported
Error: "binding not found" or "Class X not found"
Source: https://developers.cloudflare.com/durable-objects/get-started/
Why It Happens: DO class not exported from Worker
Prevention:
export class MyDO extends DurableObject { }
export default MyDO;
Issue #2: Missing Migration
Error: "migrations required" or "no migration found for class"
Source: https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
Why It Happens: Created DO class without migration entry
Prevention: Always add migration when creating new DO class
{
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }
]
}
Issue #3: Wrong Migration Type (KV vs SQLite)
Error: Schema errors, storage API mismatch
Source: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/
Why It Happens: Used new_classes instead of new_sqlite_classes
Prevention: Use new_sqlite_classes for SQLite backend (recommended)
Issue #4: Constructor Overhead Blocks Hibernation Wake
Error: Slow hibernation wake-up times
Source: https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
Why It Happens: Heavy work in constructor
Prevention: Minimize constructor, use blockConcurrencyWhile()
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
});
}
Issue #5: setTimeout Breaks Hibernation
Error: DO never hibernates, high duration charges
Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/
Why It Happens: setTimeout/setInterval prevents hibernation
Prevention: Use alarms API instead
setTimeout(() => {}, 1000);
await this.ctx.storage.setAlarm(Date.now() + 1000);
Issue #6: In-Memory State Lost on Hibernation
Error: WebSocket metadata lost, state reset unexpectedly
Source: https://developers.cloudflare.com/durable-objects/best-practices/websockets/
Why It Happens: Relied on in-memory state that's cleared on hibernation
Prevention: Use serializeAttachment() for WebSocket metadata
ws.serializeAttachment({ userId, username });
ctx.getWebSockets().forEach(ws => {
const metadata = ws.deserializeAttachment();
this.sessions.set(ws, metadata);
});
Issue #7: Outgoing WebSocket Cannot Hibernate
Error: High charges despite hibernation API
Source: https://developers.cloudflare.com/durable-objects/best-practices/websockets/
Why It Happens: Outgoing WebSockets don't support hibernation
Prevention: Only use hibernation for server-side (incoming) WebSockets
Issue #8: Global Uniqueness Confusion
Error: Unexpected DO class name conflicts
Source: https://developers.cloudflare.com/durable-objects/platform/known-issues/#global-uniqueness
Why It Happens: DO class names are globally unique per account
Prevention: Understand DO class names are shared across all Workers in account
Issue #9: Partial deleteAll on KV Backend
Error: Storage not fully deleted, billing continues
Source: https://developers.cloudflare.com/durable-objects/api/legacy-kv-storage-api/
Why It Happens: KV backend deleteAll() can fail partially
Prevention: Use SQLite backend for atomic deleteAll
Issue #10: Binding Name Mismatch
Error: Runtime error accessing DO binding
Source: https://developers.cloudflare.com/durable-objects/get-started/
Why It Happens: Binding name in wrangler.jsonc doesn't match code
Prevention: Ensure consistency
{ "bindings": [{ "name": "MY_DO", "class_name": "MyDO" }] }
env.MY_DO.getByName('instance');
Issue #11: State Size Exceeded
Error: "state limit exceeded" or storage errors
Source: https://developers.cloudflare.com/durable-objects/platform/pricing/
Why It Happens: Exceeded 1GB (SQLite) or 128MB (KV) limit
Prevention: Monitor storage size, implement cleanup with alarms
Issue #12: Migration Not Atomic
Error: Gradual deployment blocked
Source: https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/
Why It Happens: Tried to use gradual rollout with migrations
Prevention: Migrations deploy atomically across all instances
Issue #13: Location Hint Ignored
Error: DO created in wrong region
Source: https://developers.cloudflare.com/durable-objects/reference/data-location/
Why It Happens: Location hints are best-effort, not guaranteed
Prevention: Use jurisdiction for strict requirements
Issue #14: Alarm Retry Failures
Error: Tasks lost after alarm failures
Source: https://developers.cloudflare.com/durable-objects/api/alarms/
Why It Happens: Alarm handler throws errors repeatedly
Prevention: Implement idempotent alarm handlers
async alarm(info: { retryCount: number }): Promise<void> {
if (info.retryCount > 3) {
console.error('Giving up after 3 retries');
return;
}
}
Issue #15: Fetch Blocks Hibernation
Error: DO never hibernates despite using hibernation API
Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/
Why It Happens: In-progress fetch() requests prevent hibernation
Prevention: Ensure all async I/O completes before idle period
Configuration & Types
wrangler.jsonc:
{
"compatibility_date": "2025-11-23",
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] },
{ "tag": "v2", "renamed_classes": [{ "from": "Counter", "to": "CounterV2" }] }
]
}
TypeScript:
import { DurableObject, DurableObjectState, DurableObjectNamespace } from 'cloudflare:workers';
interface Env { MY_DO: DurableObjectNamespace<MyDurableObject>; }
export class MyDurableObject extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
}
}
Official Documentation
Questions? Issues?
- Check
references/top-errors.md for common problems
- Review
templates/ for working examples
- Consult official docs: https://developers.cloudflare.com/durable-objects/
- Verify migrations configuration carefully