| name | cloudflare-durable-objects |
| description | Comprehensive guide for Cloudflare Durable Objects - globally unique, stateful objects for coordination, real-time communication, and persistent state management.
Use when: building real-time applications, creating WebSocket servers with hibernation, implementing chat rooms or multiplayer games, coordinating between multiple clients, managing per-user or per-room state, implementing rate limiting or session management, scheduling tasks with alarms, building queues or workflows, or encountering "do class export", "new_sqlite_classes", "migrations required", "websocket hibernation", "alarm api error", or "global uniqueness" errors.
Prevents 15+ documented issues: class not exported, missing migrations, wrong migration type, constructor overhead blocking hibernation, setTimeout breaking hibernation, in-memory state lost on hibernation, outgoing WebSocket not hibernating, global uniqueness confusion, partial deleteAll on KV backend, binding name mismatches, state size limits exceeded, non-atomic migrations, location hints misunderstood, alarm retry failures, and fetch calls blocking hibernation.
Keywords: durable objects, cloudflare do, DurableObject class, do bindings, websocket hibernation, do state api, ctx.storage.sql, ctx.acceptWebSocket, webSocketMessage, alarm() handler, storage.setAlarm, idFromName, newUniqueId, getByName, DurableObjectStub, serializeAttachment, real-time cloudflare, multiplayer cloudflare, chat room workers, coordination cloudflare, stateful workers, new_sqlite_classes, do migrations, location hints, RPC methods, blockConcurrencyWhile, "do class export", "new_sqlite_classes", "migrations required", "websocket hibernation", "alarm api error", "global uniqueness", "binding not found"
|
| license | MIT |
Cloudflare Durable Objects
Status: Production Ready ✅
Last Updated: 2025-10-22
Dependencies: cloudflare-worker-base (recommended)
Latest Versions: wrangler@4.43.0+, @cloudflare/workers-types@4.20251014.0+
Official Docs: https://developers.cloudflare.com/durable-objects/
What are Durable Objects?
Cloudflare Durable Objects are globally unique, stateful objects that provide:
- Single-point coordination - Each Durable Object instance is globally unique across Cloudflare's network
- Strong consistency - Transactional, serializable storage (ACID guarantees)
- Real-time communication - WebSocket Hibernation API for thousands of connections per instance
- Persistent state - Built-in SQLite database (up to 1GB) or key-value storage
- Scheduled tasks - Alarms API for future task execution
- Global distribution - Automatically routed to optimal location
- Automatic scaling - Millions of independent instances
Use Cases:
- Chat rooms and real-time collaboration
- Multiplayer game servers
- Rate limiting and session management
- Leader election and coordination
- WebSocket servers with hibernation
- Stateful workflows and queues
- Per-user or per-room logic
Quick Start (10 Minutes)
Option 1: Scaffold New DO Project
npm create cloudflare@latest my-durable-app -- \
--template=cloudflare/durable-objects-template \
--ts \
--git \
--deploy false
cd my-durable-app
npm install
npm run dev
What this creates:
- Complete Durable Objects project structure
- TypeScript configuration
- wrangler.jsonc with bindings and migrations
- Example DO class implementation
- Worker to call the DO
Option 2: Add to Existing Worker
cd my-existing-worker
npm install -D @cloudflare/workers-types
Create a Durable Object class (src/counter.ts):
import { DurableObject } from 'cloudflare:workers';
export class Counter extends DurableObject {
async increment(): Promise<number> {
let value: number = (await this.ctx.storage.get('value')) || 0;
value += 1;
await this.ctx.storage.put('value', value);
return value;
}
async get(): Promise<number> {
return (await this.ctx.storage.get('value')) || 0;
}
}
export default Counter;
Configure wrangler.jsonc:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"Counter"
]
}
]
}
Call from Worker (src/index.ts):
import { Counter } from './counter';
interface Env {
COUNTER: DurableObjectNamespace<Counter>;
}
export { Counter };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const id = env.COUNTER.idFromName('global-counter');
const stub = env.COUNTER.get(id);
const count = await stub.increment();
return new Response(`Count: ${count}`);
},
};
Deploy:
npx wrangler deploy
Durable Object Class Structure
Base Class Pattern
All Durable Objects MUST extend DurableObject from cloudflare:workers:
import { DurableObject } from 'cloudflare:workers';
export class MyDurableObject extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.someValue = await ctx.storage.get('someKey') || defaultValue;
});
}
async myMethod(): Promise<string> {
return 'Hello from DO!';
}
async fetch(request: Request): Promise<Response> {
return new Response('Hello from DO fetch!');
}
}
export default MyDurableObject;
Constructor Pattern
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.env = env;
}
CRITICAL Rules:
- ✅ Always call
super(ctx, env) first
- ✅ Keep constructor minimal - heavy work blocks hibernation wake-up
- ✅ Use
ctx.blockConcurrencyWhile() to initialize from storage before requests
- ❌ Never use
setTimeout or setInterval - breaks hibernation (use alarms instead)
- ❌ Don't rely only on in-memory state with WebSockets - persist to storage
Exporting the Class
export default MyDurableObject;
export { MyDurableObject };
In Worker:
import { MyDurableObject } from './my-durable-object';
export { MyDurableObject };
interface Env {
MY_DO: DurableObjectNamespace<MyDurableObject>;
}
State API - Persistent Storage
Durable Objects provide two storage APIs depending on the backend:
- SQL API (SQLite backend) - Recommended
- Key-Value API (KV or SQLite backend)
Enable SQLite Backend (Recommended)
In wrangler.jsonc migrations:
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyDurableObject"]
}
]
}
Why SQLite?
- ✅ Up to 1GB storage (vs 128MB for KV backend)
- ✅ Atomic operations (deleteAll is all-or-nothing)
- ✅ SQL queries with transactions
- ✅ Point-in-time recovery (PITR)
- ✅ Synchronous KV API available too
SQL API
Access via ctx.storage.sql:
import { DurableObject } from 'cloudflare:workers';
export class MyDurableObject 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 AUTOINCREMENT,
text TEXT NOT NULL,
user TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_created_at ON messages(created_at);
`);
}
async addMessage(text: string, user: string): Promise<number> {
const cursor = this.sql.exec(
'INSERT INTO messages (text, user, created_at) VALUES (?, ?, ?) RETURNING id',
text,
user,
Date.now()
);
const row = cursor.one<{ id: number }>();
return row.id;
}
async getMessages(limit: number = 50): Promise<any[]> {
const cursor = this.sql.exec(
'SELECT * FROM messages ORDER BY created_at DESC LIMIT ?',
limit
);
return cursor.toArray();
}
async deleteOldMessages(beforeTimestamp: number): Promise<void> {
this.sql.exec(
'DELETE FROM messages WHERE created_at < ?',
beforeTimestamp
);
}
}
SQL API Methods:
const cursor = this.sql.exec('SELECT * FROM table WHERE id = ?', id);
const row = cursor.one<RowType>();
const row = cursor.one<RowType>({ allowNone: true });
const rows = cursor.toArray<RowType>();
for (const row of cursor) {
}
this.ctx.storage.transactionSync(() => {
this.sql.exec('INSERT INTO table1 ...');
this.sql.exec('UPDATE table2 ...');
});
CRITICAL SQL Rules:
- ✅ Always use parameterized queries with
? placeholders
- ✅ Create indexes for frequently queried columns
- ✅ Use transactions for multi-statement operations
- ❌ Don't access the hidden
__cf_kv table (used internally for KV API)
- ❌ Don't enable SQLite on existing deployed KV-backed DOs (not supported)
Key-Value API
Available on both SQLite and KV backends via ctx.storage:
import { DurableObject } from 'cloudflare:workers';
export class MyDurableObject extends DurableObject {
async increment(): Promise<number> {
let count = await this.ctx.storage.get<number>('count') || 0;
count += 1;
await this.ctx.storage.put('count', count);
return count;
}
async batchOperations(): Promise<void> {
const map = await this.ctx.storage.get<number>(['key1', 'key2', 'key3']);
await this.ctx.storage.put({
key1: 'value1',
key2: 'value2',
key3: 'value3',
});
await this.ctx.storage.delete('key1');
await this.ctx.storage.delete(['key2', 'key3']);
}
async listKeys(): Promise<string[]> {
const map = await this.ctx.storage.list();
return Array.from(map.keys());
const mapWithPrefix = await this.ctx.storage.list({
prefix: 'user:',
limit: 100,
});
}
async deleteAllStorage(): Promise<void> {
await this.ctx.storage.deleteAlarm();
await this.ctx.storage.deleteAll();
}
}
KV API Methods:
const value = await this.ctx.storage.get<T>('key');
const map = await this.ctx.storage.get<T>(['key1', 'key2']);
await this.ctx.storage.put('key', value);
await this.ctx.storage.put({ key1: value1, key2: value2 });
await this.ctx.storage.delete('key');
await this.ctx.storage.delete(['key1', 'key2']);
const map = await this.ctx.storage.list<T>({
prefix: 'user:',
limit: 100,
reverse: false
});
await this.ctx.storage.deleteAll();
await this.ctx.storage.transaction(async (txn) => {
await txn.put('key1', value1);
await txn.put('key2', value2);
});
Storage Limits:
- SQLite backend: Up to 1GB storage per DO instance
- KV backend: Up to 128MB storage per DO instance
WebSocket Hibernation API
The WebSocket Hibernation API allows Durable Objects to:
- Handle thousands of WebSocket connections per instance
- Hibernate when idle (no messages, no events) to save costs
- Wake up automatically when messages arrive
- Maintain connections without incurring duration charges during idle periods
Use for: Chat rooms, real-time collaboration, multiplayer games, live updates
How Hibernation Works
- Active state - DO is in memory, handling messages
- Idle state - No messages for ~10 seconds, DO can hibernate
- Hibernation - In-memory state cleared, WebSockets stay connected to Cloudflare edge
- Wake up - New message arrives → constructor runs → handler method called
CRITICAL: In-memory state is lost on hibernation. Use serializeAttachment() to persist per-WebSocket metadata.
WebSocket Server Pattern
import { DurableObject } from 'cloudflare:workers';
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) => {
const attachment = ws.deserializeAttachment();
this.sessions.set(ws, attachment);
});
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader !== 'websocket') {
return new Response('Expected websocket', { status: 426 });
}
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
const url = new URL(request.url);
const userId = url.searchParams.get('userId') || 'anonymous';
const username = url.searchParams.get('username') || 'Anonymous';
this.ctx.acceptWebSocket(server);
const metadata = { userId, username };
server.serializeAttachment(metadata);
this.sessions.set(server, metadata);
this.broadcast(`${username} joined`, server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const session = this.sessions.get(ws);
if (typeof message === 'string') {
const data = JSON.parse(message);
if (data.type === 'chat') {
this.broadcast(`${session?.username}: ${data.text}`, ws);
}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const session = this.sessions.get(ws);
this.sessions.delete(ws);
ws.close(code, 'Durable Object closing WebSocket');
if (session) {
this.broadcast(`${session.username} left`);
}
}
async webSocketError(ws: WebSocket, error: any): Promise<void> {
console.error('WebSocket error:', error);
const session = this.sessions.get(ws);
this.sessions.delete(ws);
}
broadcast(message: string, except?: WebSocket): void {
this.sessions.forEach((session, ws) => {
if (ws !== except && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'message', text: message }));
}
});
}
}
WebSocket Handler Methods:
async webSocketMessage(
ws: WebSocket,
message: string | ArrayBuffer
): Promise<void> {
}
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean
): Promise<void> {
}
async webSocketError(
ws: WebSocket,
error: any
): Promise<void> {
}
Hibernation-Safe Patterns:
this.ctx.acceptWebSocket(server);
server.accept();
server.serializeAttachment({ userId: '123', username: 'Alice' });
constructor(ctx, env) {
super(ctx, env);
ctx.getWebSockets().forEach((ws) => {
const metadata = ws.deserializeAttachment();
this.sessions.set(ws, metadata);
});
}
setTimeout(() => { }, 1000);
await this.ctx.storage.setAlarm(Date.now() + 60000);
When Hibernation Does NOT Occur:
setTimeout or setInterval callbacks are pending
- In-progress
fetch() request (awaited I/O)
- Standard WebSocket API is used (not hibernation API)
- Request/event is still being processed
Alarms API - Scheduled Tasks
The Alarms API allows Durable Objects to schedule themselves to wake up at a specific time in the future.
Use for: Batching, cleanup jobs, reminders, periodic tasks, delayed operations
Basic Alarm Pattern
import { DurableObject } from 'cloudflare:workers';
export class Batcher extends DurableObject {
buffer: string[];
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.buffer = await ctx.storage.get('buffer') || [];
});
}
async addItem(item: string): Promise<void> {
this.buffer.push(item);
await this.ctx.storage.put('buffer', this.buffer);
const currentAlarm = await this.ctx.storage.getAlarm();
if (currentAlarm === null) {
await this.ctx.storage.setAlarm(Date.now() + 10000);
}
}
async alarm(alarmInfo: { retryCount: number; isRetry: boolean }): Promise<void> {
console.log(`Alarm fired (retry count: ${alarmInfo.retryCount})`);
if (this.buffer.length > 0) {
await this.processBatch(this.buffer);
this.buffer = [];
await this.ctx.storage.put('buffer', []);
}
}
async processBatch(items: string[]): Promise<void> {
console.log(`Processing ${items.length} items:`, items);
}
}
Alarm API Methods:
await this.ctx.storage.setAlarm(Date.now() + 60000);
await this.ctx.storage.setAlarm(new Date('2025-12-31T23:59:59Z'));
const alarmTime = await this.ctx.storage.getAlarm();
await this.ctx.storage.deleteAlarm();
async alarm(alarmInfo: { retryCount: number; isRetry: boolean }): Promise<void> {
}
Alarm Behavior:
- ✅ Guaranteed at-least-once execution - will retry on failure
- ✅ Automatic retries - up to 6 retries with exponential backoff (starting at 2 seconds)
- ✅ Persistent - survives DO hibernation and eviction
- ✅ Automatically deleted after successful execution
- ⚠️ One alarm per DO - setting a new alarm overwrites the previous one
Retry Pattern (Idempotent Operations):
async alarm(alarmInfo: { retryCount: number; isRetry: boolean }): Promise<void> {
if (alarmInfo.retryCount > 3) {
console.error('Alarm failed after 3 retries, giving up');
return;
}
try {
await this.sendNotification();
} catch (error) {
console.error('Alarm failed:', error);
throw error;
}
}
RPC vs HTTP Fetch
Durable Objects support two invocation patterns:
- RPC (Remote Procedure Call) - Recommended for new projects
- HTTP Fetch - For HTTP request/response flows or legacy compatibility
RPC Pattern (Recommended)
Enable RPC with compatibility date >= 2024-04-03:
{
"compatibility_date": "2025-10-22"
}
Define RPC methods on DO class:
export class Counter extends DurableObject {
async increment(): Promise<number> {
let value = await this.ctx.storage.get<number>('count') || 0;
value += 1;
await this.ctx.storage.put('count', value);
return value;
}
async decrement(): Promise<number> {
let value = await this.ctx.storage.get<number>('count') || 0;
value -= 1;
await this.ctx.storage.put('count', value);
return value;
}
async get(): Promise<number> {
return await this.ctx.storage.get<number>('count') || 0;
}
}
Call from Worker:
const id = env.COUNTER.idFromName('my-counter');
const stub = env.COUNTER.get(id);
const count = await stub.increment();
const current = await stub.get();
RPC Benefits:
- ✅ Type-safe - TypeScript knows method signatures
- ✅ Simple - Direct method calls, no HTTP ceremony
- ✅ Automatic serialization - Handles structured data
- ✅ Exception propagation - Errors thrown in DO are received in Worker
HTTP Fetch Pattern
Define fetch() handler on DO class:
export class Counter extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/increment' && request.method === 'POST') {
let value = await this.ctx.storage.get<number>('count') || 0;
value += 1;
await this.ctx.storage.put('count', value);
return new Response(JSON.stringify({ count: value }));
}
if (url.pathname === '/get' && request.method === 'GET') {
let value = await this.ctx.storage.get<number>('count') || 0;
return new Response(JSON.stringify({ count: value }));
}
return new Response('Not found', { status: 404 });
}
}
Call from Worker:
const id = env.COUNTER.idFromName('my-counter');
const stub = env.COUNTER.get(id);
const response = await stub.fetch('https://fake-host/increment', {
method: 'POST',
});
const data = await response.json();
When to Use Each
| Use Case | Recommendation |
|---|
| New project | ✅ RPC (simpler, type-safe) |
| HTTP request/response flow | HTTP Fetch |
| Complex routing logic | HTTP Fetch |
| Type safety important | ✅ RPC |
| Legacy compatibility | HTTP Fetch |
| WebSocket upgrades | HTTP Fetch (required) |
Creating Durable Object Stubs and Routing
To interact with a Durable Object from a Worker, you need to:
- Get a Durable Object ID
- Create a stub from the ID
- Call methods on the stub
Getting Durable Object IDs
Three methods to create IDs:
1. idFromName(name) - Named DOs (Most Common)
Use when you want consistent routing to the same DO instance based on a name:
const roomId = env.CHAT_ROOM.idFromName('room-123');
const userId = env.USER_SESSION.idFromName('user-alice');
const globalCounter = env.COUNTER.idFromName('global');
Use for:
- Chat rooms (name = room ID)
- User sessions (name = user ID)
- Per-tenant logic (name = tenant ID)
- Global singletons (name = 'global')
Characteristics:
- ✅ Deterministic - same name = same DO instance
- ✅ Easy to reference - just need the name string
- ⚠️ First access latency - ~100-300ms for global uniqueness check
- ⚠️ Cached after first use - subsequent access is fast
2. newUniqueId() - Random IDs
Use when you need a new, unique DO instance:
const id = env.MY_DO.newUniqueId();
const euId = env.MY_DO.newUniqueId({ jurisdiction: 'eu' });
const idString = id.toString();
await env.KV.put('session:123', idString);
Use for:
- Creating new sessions/rooms that don't exist yet
- One-time use DOs
- When you don't have a natural name
Characteristics:
- ✅ Lower latency on first use (no global uniqueness check)
- ⚠️ Must store ID to access same DO later
- ⚠️ ID format is opaque - can't derive meaning from it
3. idFromString(idString) - Recreate from Saved ID
Use when you've previously stored an ID and need to recreate it:
const idString = await env.KV.get('session:123');
const id = env.MY_DO.idFromString(idString);
const stub = env.MY_DO.get(id);
Throws exception if:
- ID string is invalid
- ID was not created from the same
DurableObjectNamespace
Getting Stubs
Method 1: get(id) - From ID
const id = env.MY_DO.idFromName('my-instance');
const stub = env.MY_DO.get(id);
await stub.myMethod();
Method 2: getByName(name) - Shortcut for Named DOs
const stub = env.MY_DO.getByName('my-instance');
await stub.myMethod();
Recommended for named DOs (cleaner code).
Location Hints (Geographic Routing)
Control WHERE a Durable Object is created with location hints:
const id = env.MY_DO.idFromName('user-alice');
const stub = env.MY_DO.get(id, { locationHint: 'enam' });
When to use:
- ✅ Create DO near user's location (lower latency)
- ✅ Data residency requirements (e.g., EU users → weur/eeur)
Limitations:
- ⚠️ Hints are best-effort - not guaranteed
- ⚠️ Only affects first creation - subsequent access uses existing location
- ⚠️ Cannot move existing DOs - once created, location is fixed
Jurisdiction Restriction (Data Residency)
Enforce strict data location requirements:
const euId = env.MY_DO.newUniqueId({ jurisdiction: 'eu' });
Use for:
- Regulatory compliance (GDPR, FedRAMP)
- Data sovereignty requirements
CRITICAL:
- ✅ Strictly enforced - DO will never leave jurisdiction
- ⚠️ Cannot combine jurisdiction with location hints
- ⚠️ Higher latency for users outside jurisdiction
Migrations - Managing DO Classes
Migrations are REQUIRED when you:
- Create a new DO class
- Rename a DO class
- Delete a DO class
- Transfer a DO class to another Worker
Migration Types:
1. Create New DO Class
{
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"Counter"
]
}
]
}
For KV backend (legacy):
{
"migrations": [
{
"tag": "v1",
"new_classes": ["Counter"]
}
]
}
CRITICAL:
- ✅ Use
new_sqlite_classes for new DOs (up to 1GB storage)
- ❌ Cannot enable SQLite on existing deployed KV-backed DOs
2. Rename DO Class
{
"durable_objects": {
"bindings": [
{
"name": "MY_DO",
"class_name": "NewClassName"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["OldClassName"]
},
{
"tag": "v2",
"renamed_classes": [
{
"from": "OldClassName",
"to": "NewClassName"
}
]
}
]
}
What happens:
- ✅ Existing DO instances keep their data
- ✅ Old bindings automatically forward to new class
- ⚠️ Must export new class in Worker code
3. Delete DO Class
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
},
{
"tag": "v2",
"deleted_classes": ["Counter"]
}
]
}
What happens:
- ✅ Existing DO instances are deleted immediately
- ✅ All storage is deleted
- ⚠️ Cannot undo - data is permanently lost
Before deleting:
- Export data if needed
- Update Workers that reference this DO
4. Transfer DO Class to Another Worker
{
"durable_objects": {
"bindings": [
{
"name": "TRANSFERRED_DO",
"class_name": "TransferredClass"
}
]
},
"migrations": [
{
"tag": "v1",
"transferred_classes": [
{
"from": "OriginalClass",
"from_script": "original-worker",
"to": "TransferredClass"
}
]
}
]
}
What happens:
- ✅ DO instances move to new Worker
- ✅ All storage is transferred
- ✅ Old bindings automatically forward
- ⚠️ Destination class must be exported
Migration Rules
CRITICAL Migration Gotchas:
❌ Migrations are ATOMIC - cannot gradual deploy
- All instances migrate at once when you deploy
- No partial rollout support
❌ Migration tags must be unique
- Cannot reuse tags
- Tags are append-only
❌ Cannot enable SQLite on existing KV-backed DOs
- Must create new DO class instead
✅ Code changes don't need migrations
- Only schema changes (new/rename/delete/transfer) need migrations
- You can deploy code updates freely
✅ Global uniqueness is per account
- DO class names are unique across your entire account
- Even across different Workers
Common Patterns
Pattern 1: Rate Limiting (Per-User)
export class RateLimiter extends DurableObject {
async checkLimit(userId: string, limit: number, window: number): Promise<boolean> {
const key = `rate:${userId}`;
const now = Date.now();
const requests = await this.ctx.storage.get<number[]>(key) || [];
const validRequests = requests.filter(timestamp => now - timestamp < window);
if (validRequests.length >= limit) {
return false;
}
validRequests.push(now);
await this.ctx.storage.put(key, validRequests);
return true;
}
}
const limiter = env.RATE_LIMITER.getByName(userId);
const allowed = await limiter.checkLimit(userId, 100, 60000);
if (!allowed) {
return new Response('Rate limit exceeded', { status: 429 });
}
Pattern 2: Session Management
export class UserSession 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 session (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER
);
`);
ctx.blockConcurrencyWhile(async () => {
const alarm = await ctx.storage.getAlarm();
if (alarm === null) {
await ctx.storage.setAlarm(Date.now() + 3600000);
}
});
}
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 get(key: string): Promise<any | null> {
const cursor = this.sql.exec(
'SELECT value, expires_at FROM session WHERE key = ?',
key
);
const row = cursor.one<{ value: string; expires_at: number | null }>({ allowNone: true });
if (!row) {
return null;
}
if (row.expires_at && row.expires_at < Date.now()) {
this.sql.exec('DELETE FROM session WHERE key = ?', key);
return null;
}
return JSON.parse(row.value);
}
async alarm(): Promise<void> {
this.sql.exec('DELETE FROM session WHERE expires_at < ?', Date.now());
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}
}
Pattern 3: Leader Election
export class LeaderElection 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 leader (
id INTEGER PRIMARY KEY CHECK (id = 1),
worker_id TEXT NOT NULL,
elected_at INTEGER NOT NULL
);
`);
}
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 (error) {
return false;
}
}
async getLeader(): Promise<string | null> {
const cursor = this.sql.exec('SELECT worker_id FROM leader WHERE id = 1');
const row = cursor.one<{ worker_id: string }>({ allowNone: true });
return row?.worker_id || null;
}
async releaseLeadership(workerId: string): Promise<void> {
this.sql.exec('DELETE FROM leader WHERE id = 1 AND worker_id = ?', workerId);
}
}
Pattern 4: Multi-DO Coordination
export class GameCoordinator extends DurableObject {
async createGame(gameId: string, env: Env): Promise<void> {
const gameRoom = env.GAME_ROOM.getByName(gameId);
await gameRoom.initialize();
await this.ctx.storage.put(`game:${gameId}`, {
id: gameId,
created: Date.now(),
});
}
async listGames(): Promise<string[]> {
const games = await this.ctx.storage.list({ prefix: 'game:' });
return Array.from(games.keys()).map(key => key.replace('game:', ''));
}
}
export class GameRoom extends DurableObject {
async initialize(): Promise<void> {
await this.ctx.storage.put('state', {
players: [],
started: false,
});
}
async addPlayer(playerId: string): Promise<void> {
const state = await this.ctx.storage.get('state');
state.players.push(playerId);
await this.ctx.storage.put('state', state);
}
}
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 Reference
Complete wrangler.jsonc Example
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter"
},
{
"name": "CHAT_ROOM",
"class_name": "ChatRoom"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"Counter",
"ChatRoom"
]
},
{
"tag": "v2",
"renamed_classes": [
{
"from": "Counter",
"to": "CounterV2"
}
]
}
]
}
TypeScript Types
import { DurableObject, DurableObjectState, DurableObjectNamespace } from 'cloudflare:workers';
interface Env {
MY_DO: DurableObjectNamespace<MyDurableObject>;
DB: D1Database;
}
export class MyDurableObject extends DurableObject<Env> {
sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
}
async myMethod(): Promise<string> {
await this.env.DB.prepare('...').run();
return 'Hello';
}
}
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