
Cloudflare Durable Objects
- 137 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Helps with ai & agent building tasks during AI-assisted development.
About
cloudflare-durable-objects is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- cloudflare-durable-objects
- AI & Agent Building
- AI-coding skill
Cloudflare Durable Objects by the numbers
- 137 all-time installs (skills.sh)
- +11 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,529 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill cloudflare-durable-objectsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 137 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Cloudflare Durable Objects
Status: Production Ready ✅ Last Updated: 2025-11-25 Dependencies: cloudflare-worker-base (recommended) Latest Versions: wrangler@4.50.0+, @cloudflare/workers-types@4.20251125.0+ Official Docs: https://developers.cloudflare.com/durable-objects/
Table of Contents
What are Durable Objects? • Quick Start • When to Load References • Class Structure • State API • WebSocket Hibernation • Alarms • RPC vs HTTP • Stubs & Routing • Migrations • Common Patterns • Critical Rules • Known Issues
What are Durable Objects?
Globally unique, stateful objects with single-point coordination, strong consistency (ACID), WebSocket Hibernation (thousands of connections), SQLite storage (1GB), and alarms API.
Use for: Chat rooms, multiplayer games, rate limiting, session management, leader election, stateful workflows ---
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 && bun install && npm run devOption 2: Add to Existing Worker
1. Install types:
bun add -d @cloudflare/workers-types2. Create DO 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;
await this.ctx.storage.put('value', ++value);
return value;
}
}
export default Counter; // CRITICAL3. Configure (wrangler.jsonc):
{
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] }
]
}4. 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 stub = env.COUNTER.getByName('global-counter');
return new Response(`Count: ${await stub.increment()}`);
},
};Deploy:
bunx wrangler deploy---
Available Commands
Use these interactive commands for guided workflows:
- `/do-setup` - Initialize new DO project with interactive setup wizard
- Choose storage backend (SQL, KV, both)
- Select use case pattern (WebSocket, Sessions, Rate Limiting, etc.)
- Optional Vitest testing setup
- Generates complete DO implementation
- `/do-migrate` - Interactive migration assistant
- New class creation (new_sqlite_classes, new_classes)
- Rename existing classes (renamed_classes)
- Delete classes with safety confirmations (deleted_classes)
- Transfer classes between scripts (transferred_classes)
- Auto-increments migration tags (v1, v2, v3...)
- `/do-debug` - Step-by-step debugging workflow
- Detects error categories (deployment, runtime, performance, etc.)
- Runs diagnostic checks on configuration and code
- Provides specific fixes with code examples
- Guides local testing and production verification
- `/do-patterns` - Pattern selection wizard
- Recommends DO pattern based on use case
- Supports WebSocket, Rate Limiting, Sessions, Analytics, Leader Election
- Generates complete pattern implementation
- Provides best practices and optimization tips
- `/do-optimize` - Performance optimization assistant
- Analyzes existing DO code for bottlenecks
- Provides targeted optimization recommendations
- Covers constructor, queries, WebSocket, memory, alarms
- Measures performance improvements
Autonomous Agents
These agents work autonomously without user interaction:
- `do-debugger` - Automatic error detection and fixing
- Validates wrangler.jsonc configuration
- Detects 16+ common DO errors
- Applies fixes automatically with backups
- Tests fixes before reporting
- `do-setup-assistant` - Automatic project scaffolding
- Analyzes user requirements from natural language
- Generates complete DO implementation
- Creates tests, documentation, validation
- Supports all use case patterns
- `do-pattern-implementer` - Production pattern implementation
- Analyzes existing DO code
- Recommends patterns by priority
- Implements TTL cleanup, RPC metadata, SQL indexes, etc.
- Generates pattern-specific tests
---
When to Load References
Load immediately when user mentions:
- `state-api-reference.md` → "storage", "sql", "database", "query", "get/put", "KV", "1GB limit"
- `websocket-hibernation.md` → "websocket", "real-time", "chat", "hibernation", "serializeAttachment"
- `alarms-api.md` → "alarms", "scheduled tasks", "cron", "periodic", "batch processing"
- `rpc-patterns.md` → "RPC", "fetch", "HTTP", "methods", "routing"
- `rpc-metadata.md` → "RpcTarget", "metadata", "DO name", "idFromName access"
- `stubs-routing.md` → "stubs", "idFromName", "newUniqueId", "location hints", "jurisdiction"
- `migrations-guide.md` → "migrations", "rename", "delete", "transfer", "schema changes"
- `migration-cheatsheet.md` → "migration quick reference", "migration types", "common migrations"
- `common-patterns.md` → "patterns", "examples", "rate limiting", "sessions", "leader election"
- `vitest-testing.md` → "test", "testing", "vitest", "unit test", "@cloudflare/vitest-pool-workers"
- `gradual-deployments.md` → "gradual", "deployment", "traffic split", "rollout", "canary"
- `typescript-config.md` → "TypeScript", "types", "tsconfig", "wrangler.jsonc", "bindings"
- `advanced-sql-patterns.md` → "CTE", "window functions", "FTS5", "full-text search", "JSON functions", "complex SQL"
- `security-best-practices.md` → "security", "authentication", "authorization", "SQL injection", "CORS", "encryption", "rate limiting"
- `error-codes.md` → "error codes", "error catalog", "specific error", "E001", "troubleshooting"
- `top-errors.md` → errors, "not working", debugging, "binding not found"
Load proactively when:
- Building new feature → Load relevant pattern from
common-patterns.md - Debugging issue → Load
error-codes.mdfor specific errors, thentop-errors.md - Implementing WebSocket → Load
websocket-hibernation.mdbefore coding - Setting up storage → Load
state-api-reference.mdfor SQL/KV APIs - Complex SQL queries → Load
advanced-sql-patterns.mdfor CTEs, window functions, FTS5 - Security review → Load
security-best-practices.mdfor authentication, authorization, SQL injection prevention - Creating first DO → Load
stubs-routing.mdfor ID methods - Writing tests → Load
vitest-testing.mdfor testing patterns - Planning deployment → Load
gradual-deployments.mdfor rollout strategy - Migration needed → Load
migration-cheatsheet.mdfor quick reference - Using DO name inside DO → Load
rpc-metadata.mdfor RpcTarget pattern - TypeScript configuration → Load
typescript-config.mdfor setup
---
Durable Object Class Structure
All DOs extend DurableObject and MUST be exported:
import { DurableObject } from 'cloudflare:workers';
export class MyDO extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env); // Required first line
// Keep minimal - heavy work blocks hibernation
ctx.blockConcurrencyWhile(async () => {
// Load from storage before handling requests
});
}
async myMethod(): Promise<string> { // RPC method (recommended)
return 'Hello!';
}
}
export default MyDO; // CRITICAL: Must export`this.ctx` provides: storage (SQL/KV), id (unique ID), waitUntil(), acceptWebSocket()
---
State API - Persistent Storage
Durable Objects provide two storage options:
SQL API (SQLite backend, recommended):
- Access via
ctx.storage.sql - Up to 1GB storage per instance
- SQL queries with transactions, indexes, cursors
- Atomic operations (deleteAll is all-or-nothing)
- Use
new_sqlite_classesin migrations
Key-Value API (available on both backends):
- Access via
ctx.storage(get/put/delete/list) - Simple key-value operations
- Async transactions supported
- 128MB limit on KV backend, 1GB on SQLite
Quick example:
export class Counter 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 counts (key TEXT PRIMARY KEY, value INTEGER)');
}
async increment(): Promise<number> {
this.sql.exec('INSERT OR REPLACE INTO counts (key, value) VALUES (?, ?)', 'count', 1);
return this.sql.exec('SELECT value FROM counts WHERE key = ?', 'count').one<{value: number}>().value;
}
}Load `references/state-api-reference.md` for complete SQL and KV API documentation, cursor operations, transactions, parameterized queries, storage limits, and migration patterns.
---
WebSocket Hibernation API
Handle thousands of WebSocket connections per DO instance with automatic hibernation when idle (~10s no activity), saving duration costs. Connections stay open at the edge while DO sleeps.
CRITICAL Rules:
- ✅ Use
ctx.acceptWebSocket(server)(enables hibernation) - ✅ Use
ws.serializeAttachment(data)to persist metadata across hibernation - ✅ Restore connections in constructor with
ctx.getWebSockets() - ❌ Don't use
ws.accept()(standard API, no hibernation) - ❌ Don't use
setTimeout/setInterval(prevents hibernation)
Handler methods: webSocketMessage(), webSocketClose(), webSocketError()
Quick pattern:
export class ChatRoom extends DurableObject {
sessions: Map<WebSocket, any>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sessions = new Map();
// Restore connections after hibernation
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);
this.ctx.acceptWebSocket(server); // ← Enables hibernation
server.serializeAttachment({ userId: 'alice' }); // ← Persists across hibernation
this.sessions.set(server, { userId: 'alice' });
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
const session = this.sessions.get(ws);
// Broadcast to all
this.sessions.forEach((_, w) => w.send(message));
}
}Load `references/websocket-hibernation.md` for complete handler patterns, hibernation lifecycle, serializeAttachment API, connection management, broadcasting patterns, and hibernation troubleshooting.
---
Alarms API - Scheduled Tasks
Schedule DO to wake up at a future time for batching, cleanup, reminders, or periodic tasks.
Core API:
await ctx.storage.setAlarm(timestamp)- Schedule alarmawait ctx.storage.getAlarm()- Get current alarm time (null if not set)await ctx.storage.deleteAlarm()- Cancel alarmasync alarm(info)- Handler called when alarm fires
Key Features:
- ✅ Guaranteed at-least-once execution with automatic retries (up to 6)
- ✅ Survives hibernation and eviction
- ✅ Deleted automatically after successful execution
- ⚠️ Only ONE alarm per DO (setting new one overwrites previous)
Quick pattern:
export class Batcher extends DurableObject {
async addItem(item: string): Promise<void> {
await this.ctx.storage.put('items', [...existingItems, item]);
// Schedule batch processing if not already scheduled
if (await this.ctx.storage.getAlarm() === null) {
await this.ctx.storage.setAlarm(Date.now() + 10000); // 10 seconds
}
}
async alarm(info: { retryCount: number; isRetry: boolean }): Promise<void> {
const items = await this.ctx.storage.get('items');
await this.processBatch(items); // Send to API, write to DB, etc.
await this.ctx.storage.put('items', []); // Clear buffer
}
}Load `references/alarms-api.md` for periodic alarms pattern, retry handling, error scenarios, cleanup jobs, and batching strategies.
---
RPC vs HTTP Fetch
RPC (Recommended): Call DO methods directly like await stub.increment(). Type-safe, simple, auto-serialization. Requires compatibility_date >= 2024-04-03.
HTTP Fetch: Traditional HTTP request/response with async fetch(request) handler. Required for WebSocket upgrades.
Quick comparison:
// RPC Pattern (simpler)
export class Counter extends DurableObject {
async increment(): Promise<number> { // ← Direct method
let value = await this.ctx.storage.get<number>('count') || 0;
return ++value;
}
}
const count = await stub.increment(); // ← Direct call
// HTTP Fetch Pattern
export class Counter extends DurableObject {
async fetch(request: Request): Promise<Response> { // ← HTTP handler
const url = new URL(request.url);
if (url.pathname === '/increment') { /* ... */ }
}
}
const response = await stub.fetch('/increment', { method: 'POST' });Use RPC for: New projects, type safety, simple method calls Use HTTP Fetch for: WebSocket upgrades, complex routing, legacy code
Load `references/rpc-patterns.md` for complete RPC vs Fetch comparison, migration guide, error handling patterns, and method visibility control.
---
Creating Durable Object Stubs and Routing
To interact with a Durable Object from a Worker: get an ID → create a stub → call methods.
Three ID creation methods:
1. `idFromName(name)` - Named DOs (most common): Deterministic routing to same instance globally 2. `newUniqueId()` - Random IDs: New unique instance, must store ID for future access 3. `idFromString(idString)` - Recreate from saved ID string
Getting stubs:
// Method 1: From ID
const id = env.CHAT_ROOM.idFromName('room-123');
const stub = env.CHAT_ROOM.get(id);
// Method 2: Shortcut for named DOs (recommended)
const stub = env.CHAT_ROOM.getByName('room-123');
await stub.myMethod();Geographic routing with location hints:
- Set
locationHintoption when creating stub:{ locationHint: 'enam' } - 9 regions: wnam, enam, sam, weur, eeur, apac, oc, afr, me
- Best-effort (not guaranteed), only affects first creation
Data residency with jurisdiction restrictions:
- Use
newUniqueId({ jurisdiction: 'eu' })or{ jurisdiction: 'fedramp' } - Strictly enforced (DO never leaves jurisdiction)
- Cannot combine with location hints
- Required for GDPR/FedRAMP compliance
Load `references/stubs-routing.md` for complete guide to ID methods, stub management, location hints, jurisdiction restrictions, use cases, best practices, and error handling patterns.
---
Migrations - Managing DO Classes
Migrations are REQUIRED when creating, renaming, deleting, or transferring DO classes between Workers.
Four migration types:
1. Create New DO: Use new_sqlite_classes (recommended, 1GB) or new_classes (legacy KV, 128MB) 2. Rename DO: Use renamed_classes with from/to mapping (data preserved, bindings forward) 3. Delete DO: Use deleted_classes (⚠️ immediate deletion, cannot undo, all storage lost) 4. Transfer DO: Use transferred_classes with from_script (moves instances to new Worker)
Quick example - Create new DO with SQLite:
{
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
},
"migrations": [
{
"tag": "v1", // Unique identifier (append-only)
"new_sqlite_classes": ["Counter"]
}
]
}CRITICAL rules:
- ❌ Migrations are ATOMIC (all instances migrate at once, no gradual rollout)
- ❌ Cannot enable SQLite on existing KV-backed DOs (must create new class)
- ❌ Migration tags must be unique (cannot reuse, append-only)
- ✅ Code changes don't need migrations (only schema changes do)
- ✅ DO class names are unique per account (across all Workers)
Load `references/migrations-guide.md` for complete migration patterns, rename/delete/transfer procedures, rollback strategies, and migration gotchas.
---
Common Patterns
Four production-ready patterns for Cloudflare Durable Objects:
1. Rate Limiting - Per-user rate limiting with sliding window, KV storage for request tracking 2. Session Management - User sessions with TTL, SQL storage, automatic cleanup via alarms 3. Leader Election - Single leader guarantee using SQL constraints, heartbeat mechanism 4. Multi-DO Coordination - Game coordinator + game rooms pattern, parent-child DO relationships
Quick example - Rate limiter:
export class RateLimiter extends DurableObject {
async checkLimit(userId: string, limit: number, window: number): Promise<boolean> {
const requests = await this.ctx.storage.get<number[]>(`rate:${userId}`) || [];
const validRequests = requests.filter(t => Date.now() - t < window);
if (validRequests.length >= limit) return false;
validRequests.push(Date.now());
await this.ctx.storage.put(`rate:${userId}`, validRequests);
return true;
}
}Load `references/common-patterns.md` for complete implementations of all 4 patterns with full code examples, SQL schemas, alarm usage, error handling, and best practices.
---
Critical Rules
✅ Always:
- Export DO class:
export default MyDO - Call
super(ctx, env)first in constructor - Use
new_sqlite_classesin migrations (1GB vs 128MB KV) - Use
ctx.acceptWebSocket()for hibernation (notws.accept()) - Persist state to storage (not just memory)
- Use alarms instead of setTimeout/setInterval
- Use parameterized SQL:
sql.exec('... WHERE id = ?', id) - Minimize constructor work, use
blockConcurrencyWhile()
❌ Never:
- Create DO without migration (error)
- Forget to export class (binding not found)
- Use setTimeout/setInterval (prevents hibernation)
- Rely only on in-memory state for WebSockets (use serializeAttachment)
- Deploy migrations gradually (migrations are atomic)
- Enable SQLite on existing KV-backed DO (must create new class)
- Assume location hints are guaranteed (best-effort only)
---
Known Issues Prevention
This skill prevents 15+ documented issues. Top 3 most critical:
Issue #1: Class Not Exported
Error: "binding not found" | Why: DO class not exported Fix: export default MyDO;
Issue #2: Missing Migration
Error: "migrations required" | Why: Created DO without migration entry Fix: Add { "tag": "v1", "new_sqlite_classes": ["MyDO"] } to migrations
Issue #3: setTimeout Breaks Hibernation
Error: DO never hibernates, high charges | Why: setTimeout prevents hibernation Fix: Use await ctx.storage.setAlarm(Date.now() + 1000) instead
12 more issues covered: Wrong migration type, constructor overhead, in-memory state lost, outgoing WebSocket no hibernation, global uniqueness confusion, partial deleteAll, binding mismatch, state size exceeded, migration not atomic, location hint ignored, alarm retry failures, fetch blocks hibernation.
Load `references/top-errors.md` for complete error catalog with all 15+ issues, detailed prevention strategies, debugging steps, and resolution patterns.
---
Configuration & TypeScript
Configure wrangler.jsonc with DO bindings and migrations, set up TypeScript types with proper exports.
Load `references/typescript-config.md` for: wrangler.jsonc structure, TypeScript types, Env interface, tsconfig.json, common type issues
---
Official Docs: https://developers.cloudflare.com/durable-objects/
- State API (SQL): https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/
- WebSocket Hibernation: https://developers.cloudflare.com/durable-objects/best-practices/websockets/
- Alarms API: https://developers.cloudflare.com/durable-objects/api/alarms/
- Migrations: https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
- Best Practices: https://developers.cloudflare.com/durable-objects/best-practices/
Questions? Load references/top-errors.md for common problems or check templates/ for working examples
Advanced SQL Patterns for Durable Objects
Status: Production Ready ✅ Last Verified: 2025-12-27 Official Docs: https://developers.cloudflare.com/durable-objects/api/storage-api/
Complete guide to advanced SQL features and patterns in Durable Objects SQLite backend.
---
Overview
Durable Objects SQL storage provides a full SQLite database (1GB limit) with support for:
- Common Table Expressions (CTEs)
- Window functions
- Aggregate functions
- Full-text search (FTS5)
- JSON functions
- Recursive queries
- Transactions
- Triggers
---
Common Table Expressions (CTEs)
CTEs provide temporary result sets for complex queries.
Basic CTE
// Find users with above-average activity
const result = await this.ctx.storage.sql.exec(`
WITH avg_activity AS (
SELECT AVG(activity_count) as avg_count
FROM user_stats
)
SELECT u.id, u.username, us.activity_count
FROM users u
JOIN user_stats us ON u.id = us.user_id
CROSS JOIN avg_activity
WHERE us.activity_count > avg_activity.avg_count
ORDER BY us.activity_count DESC
`);Multiple CTEs
// Complex analytics query
const result = await this.ctx.storage.sql.exec(`
WITH
daily_totals AS (
SELECT
DATE(timestamp / 1000, 'unixepoch') as date,
COUNT(*) as total_events,
SUM(value) as total_value
FROM events
GROUP BY date
),
weekly_averages AS (
SELECT
AVG(total_events) as avg_events,
AVG(total_value) as avg_value
FROM daily_totals
WHERE date >= DATE('now', '-7 days')
)
SELECT
dt.date,
dt.total_events,
dt.total_value,
dt.total_events - wa.avg_events as events_vs_avg,
dt.total_value - wa.avg_value as value_vs_avg
FROM daily_totals dt
CROSS JOIN weekly_averages wa
ORDER BY dt.date DESC
LIMIT 30
`);Recursive CTEs
// Hierarchical data (thread replies)
const result = await this.ctx.storage.sql.exec(`
WITH RECURSIVE thread_tree AS (
-- Base case: root message
SELECT id, parent_id, content, 0 as depth
FROM messages
WHERE id = ?
UNION ALL
-- Recursive case: replies
SELECT m.id, m.parent_id, m.content, tt.depth + 1
FROM messages m
JOIN thread_tree tt ON m.parent_id = tt.id
WHERE tt.depth < 10 -- Prevent infinite recursion
)
SELECT * FROM thread_tree
ORDER BY depth, id
`, rootMessageId);Use Cases:
- Hierarchical data (comments, categories)
- Graph traversal
- Bill of materials
- Organization charts
---
Window Functions
Window functions perform calculations across rows related to the current row.
ROW_NUMBER()
// Rank messages by user
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
content,
created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rank
FROM messages
`);
// Get top 3 messages per user
const top3 = await this.ctx.storage.sql.exec(`
WITH ranked_messages AS (
SELECT
user_id,
content,
created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rank
FROM messages
)
SELECT * FROM ranked_messages
WHERE rank <= 3
`);RANK() and DENSE_RANK()
// Leaderboard with tie handling
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
score,
RANK() OVER (ORDER BY score DESC) as rank,
DENSE_RANK() OVER (ORDER BY score DESC) as dense_rank
FROM leaderboard
`);
// RANK: 1, 2, 2, 4 (gaps after ties)
// DENSE_RANK: 1, 2, 2, 3 (no gaps)LAG() and LEAD()
// Compare with previous/next values
const result = await this.ctx.storage.sql.exec(`
SELECT
date,
value,
LAG(value) OVER (ORDER BY date) as previous_value,
LEAD(value) OVER (ORDER BY date) as next_value,
value - LAG(value) OVER (ORDER BY date) as change_from_prev
FROM metrics
ORDER BY date
`);Running Totals
// Cumulative sum
const result = await this.ctx.storage.sql.exec(`
SELECT
date,
amount,
SUM(amount) OVER (ORDER BY date) as running_total
FROM transactions
ORDER BY date
`);Moving Averages
// 7-day moving average
const result = await this.ctx.storage.sql.exec(`
SELECT
date,
value,
AVG(value) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as moving_avg_7day
FROM daily_metrics
ORDER BY date
`);---
Aggregate Functions
Advanced aggregation patterns beyond basic COUNT/SUM/AVG.
GROUP_CONCAT()
// Aggregate related values into array
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
GROUP_CONCAT(tag, ',') as tags
FROM user_tags
GROUP BY user_id
`);
// Result: user_id=1, tags="javascript,typescript,react"JSON Aggregation
// Build JSON arrays
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
JSON_GROUP_ARRAY(
JSON_OBJECT(
'tag', tag,
'count', count
)
) as tag_data
FROM user_tags
GROUP BY user_id
`);
// Result: user_id=1, tag_data=[{"tag":"js","count":5},{"tag":"ts","count":3}]HAVING with Aggregates
// Filter groups by aggregate conditions
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
COUNT(*) as message_count,
MAX(created_at) as last_message
FROM messages
GROUP BY user_id
HAVING COUNT(*) > 10
AND MAX(created_at) > ?
ORDER BY message_count DESC
`, Date.now() - 86400_000); // Active in last 24h---
Full-Text Search (FTS5)
SQLite FTS5 provides powerful full-text search capabilities.
Setup FTS5 Table
// Create FTS5 virtual table
await this.ctx.storage.sql.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
USING fts5(
content,
user_id UNINDEXED,
tokenize='porter unicode61'
)
`);
// Keep FTS table in sync with main table using triggers
await this.ctx.storage.sql.exec(`
CREATE TRIGGER IF NOT EXISTS messages_ai
AFTER INSERT ON messages
BEGIN
INSERT INTO messages_fts(rowid, content, user_id)
VALUES (new.id, new.content, new.user_id);
END
`);
await this.ctx.storage.sql.exec(`
CREATE TRIGGER IF NOT EXISTS messages_au
AFTER UPDATE ON messages
BEGIN
UPDATE messages_fts
SET content = new.content, user_id = new.user_id
WHERE rowid = old.id;
END
`);
await this.ctx.storage.sql.exec(`
CREATE TRIGGER IF NOT EXISTS messages_ad
AFTER DELETE ON messages
BEGIN
DELETE FROM messages_fts WHERE rowid = old.id;
END
`);Basic Search
// Simple search
const result = await this.ctx.storage.sql.exec(`
SELECT
m.id,
m.user_id,
m.content,
m.created_at
FROM messages_fts fts
JOIN messages m ON m.id = fts.rowid
WHERE messages_fts MATCH ?
ORDER BY rank
`, 'cloudflare workers');Advanced Search Syntax
// Phrase search
const phrase = await this.ctx.storage.sql.exec(`
SELECT * FROM messages_fts
WHERE messages_fts MATCH '"durable objects"'
`);
// AND search
const and = await this.ctx.storage.sql.exec(`
SELECT * FROM messages_fts
WHERE messages_fts MATCH 'cloudflare AND workers'
`);
// OR search
const or = await this.ctx.storage.sql.exec(`
SELECT * FROM messages_fts
WHERE messages_fts MATCH 'cloudflare OR workers'
`);
// NOT search
const not = await this.ctx.storage.sql.exec(`
SELECT * FROM messages_fts
WHERE messages_fts MATCH 'cloudflare NOT pages'
`);
// Prefix search
const prefix = await this.ctx.storage.sql.exec(`
SELECT * FROM messages_fts
WHERE messages_fts MATCH 'cloud*'
`);Ranked Results with Snippets
const result = await this.ctx.storage.sql.exec(`
SELECT
m.id,
m.content,
snippet(messages_fts, 0, '<mark>', '</mark>', '...', 32) as snippet,
rank as relevance
FROM messages_fts fts
JOIN messages m ON m.id = fts.rowid
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT 20
`, searchQuery);---
JSON Functions
SQLite provides powerful JSON manipulation functions.
JSON Storage
// Store JSON data
await this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS user_profiles (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
profile_data TEXT NOT NULL -- JSON as TEXT
)
`);
await this.ctx.storage.sql.exec(`
INSERT INTO user_profiles (user_id, profile_data)
VALUES (?, ?)
`, userId, JSON.stringify({
name: 'John Doe',
preferences: {
theme: 'dark',
notifications: true
},
tags: ['developer', 'cloudflare']
}));JSON Queries
// Extract JSON fields
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
JSON_EXTRACT(profile_data, '$.name') as name,
JSON_EXTRACT(profile_data, '$.preferences.theme') as theme,
JSON_EXTRACT(profile_data, '$.tags') as tags
FROM user_profiles
WHERE user_id = ?
`, userId);
// Filter by JSON values
const darkThemeUsers = await this.ctx.storage.sql.exec(`
SELECT user_id
FROM user_profiles
WHERE JSON_EXTRACT(profile_data, '$.preferences.theme') = 'dark'
`);JSON Array Operations
// Query JSON arrays
const result = await this.ctx.storage.sql.exec(`
SELECT
user_id,
JSON_EXTRACT(profile_data, '$.tags') as tags
FROM user_profiles
WHERE JSON_EXTRACT(profile_data, '$.tags') LIKE '%developer%'
`);
// Expand JSON arrays into rows
const expanded = await this.ctx.storage.sql.exec(`
SELECT
up.user_id,
jt.value as tag
FROM user_profiles up,
JSON_EACH(JSON_EXTRACT(up.profile_data, '$.tags')) jt
`);JSON Modification
// Update JSON fields
await this.ctx.storage.sql.exec(`
UPDATE user_profiles
SET profile_data = JSON_SET(
profile_data,
'$.preferences.theme',
?
)
WHERE user_id = ?
`, 'light', userId);
// Add to JSON array
await this.ctx.storage.sql.exec(`
UPDATE user_profiles
SET profile_data = JSON_INSERT(
profile_data,
'$.tags[#]',
?
)
WHERE user_id = ?
`, 'new-tag', userId);---
Advanced Transaction Patterns
Complex transaction scenarios with proper error handling.
Nested Transactions (Savepoints)
async complexTransaction() {
try {
await this.ctx.storage.sql.exec('BEGIN');
// First operation
await this.ctx.storage.sql.exec(
'INSERT INTO accounts (user_id, balance) VALUES (?, ?)',
userId, 100
);
// Savepoint for partial rollback
await this.ctx.storage.sql.exec('SAVEPOINT sp1');
try {
// Risky operation
await this.ctx.storage.sql.exec(
'UPDATE accounts SET balance = balance - ? WHERE user_id = ?',
amount, userId
);
// Check balance constraint
const result = await this.ctx.storage.sql.exec(
'SELECT balance FROM accounts WHERE user_id = ?',
userId
);
if ((result.rows[0].balance as number) < 0) {
throw new Error('Insufficient funds');
}
// Release savepoint if successful
await this.ctx.storage.sql.exec('RELEASE sp1');
} catch (error) {
// Rollback to savepoint
await this.ctx.storage.sql.exec('ROLLBACK TO sp1');
throw error;
}
await this.ctx.storage.sql.exec('COMMIT');
} catch (error) {
await this.ctx.storage.sql.exec('ROLLBACK');
throw error;
}
}Optimistic Locking
async updateWithOptimisticLock(id: number, newValue: string, expectedVersion: number) {
const result = await this.ctx.storage.sql.exec(`
UPDATE records
SET value = ?, version = version + 1
WHERE id = ? AND version = ?
`, newValue, id, expectedVersion);
if (result.rowsWritten === 0) {
throw new Error('Concurrent modification detected');
}
return expectedVersion + 1;
}Batch Insert with UPSERT
// Efficient bulk upsert
async bulkUpsert(records: Array<{id: string, value: number}>) {
await this.ctx.storage.sql.exec('BEGIN');
for (const record of records) {
await this.ctx.storage.sql.exec(`
INSERT INTO metrics (id, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`, record.id, record.value, Date.now());
}
await this.ctx.storage.sql.exec('COMMIT');
}---
Performance Patterns
Advanced optimization techniques.
Materialized Views (Manual)
// Create summary table
await this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS daily_stats (
date TEXT PRIMARY KEY,
total_users INTEGER,
total_messages INTEGER,
avg_message_length REAL,
last_updated INTEGER
)
`);
// Refresh materialized view
async refreshDailyStats(date: string) {
await this.ctx.storage.sql.exec(`
INSERT OR REPLACE INTO daily_stats (
date, total_users, total_messages, avg_message_length, last_updated
)
SELECT
?,
COUNT(DISTINCT user_id),
COUNT(*),
AVG(LENGTH(content)),
?
FROM messages
WHERE DATE(created_at / 1000, 'unixepoch') = ?
`, date, Date.now(), date);
}
// Query materialized view (fast)
const stats = await this.ctx.storage.sql.exec(
'SELECT * FROM daily_stats WHERE date >= ? ORDER BY date DESC',
'2025-01-01'
);Partial Indexes
// Index only active records
await this.ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_active_sessions
ON sessions(user_id, last_activity)
WHERE expires_at > ?
`, Date.now());
// Much smaller index, faster queries for active sessions
const activeSessions = await this.ctx.storage.sql.exec(`
SELECT * FROM sessions
WHERE user_id = ? AND expires_at > ?
ORDER BY last_activity DESC
`, userId, Date.now());Expression Indexes
// Index on computed column
await this.ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_day_of_week
ON events((strftime('%w', timestamp / 1000, 'unixepoch')))
`);
// Fast queries by day of week
const weekendEvents = await this.ctx.storage.sql.exec(`
SELECT * FROM events
WHERE strftime('%w', timestamp / 1000, 'unixepoch') IN ('0', '6')
`);---
Anti-Patterns to Avoid
❌ SELECT * in Production
// Bad: Returns all columns (wasteful)
const result = await this.ctx.storage.sql.exec('SELECT * FROM messages');
// Good: Select only needed columns
const result = await this.ctx.storage.sql.exec(
'SELECT id, content, created_at FROM messages'
);❌ OFFSET Pagination
// Bad: OFFSET gets slower as you paginate further
const page10000 = await this.ctx.storage.sql.exec(
'SELECT * FROM messages ORDER BY id LIMIT 50 OFFSET 500000'
);
// Good: Cursor-based pagination
const page = await this.ctx.storage.sql.exec(
'SELECT * FROM messages WHERE id > ? ORDER BY id LIMIT 50',
lastSeenId
);❌ OR Conditions on Different Columns
// Bad: Can't use indexes efficiently
const result = await this.ctx.storage.sql.exec(`
SELECT * FROM users
WHERE username = ? OR email = ?
`, searchTerm, searchTerm);
// Good: Use UNION
const result = await this.ctx.storage.sql.exec(`
SELECT * FROM users WHERE username = ?
UNION
SELECT * FROM users WHERE email = ?
`, searchTerm, searchTerm);❌ Functions on Indexed Columns in WHERE
// Bad: Can't use index
const result = await this.ctx.storage.sql.exec(`
SELECT * FROM events
WHERE LOWER(category) = ?
`, 'important');
// Good: Store lowercase version or use expression index
await this.ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_category_lower
ON events(LOWER(category))
`);---
Sources
- SQLite Documentation
- Cloudflare Durable Objects SQL
- SQLite Window Functions
- SQLite JSON Functions
- SQLite FTS5
---
Last Updated: 2025-12-27 Maintainer: Claude Skills Team
Alarms API - Scheduled Tasks
Complete guide to scheduling future tasks with alarms.
---
What are Alarms?
Alarms allow Durable Objects to schedule themselves to wake up at a specific time in the future.
Use Cases:
- Batching (accumulate items, process in bulk)
- Cleanup (delete old data periodically)
- Reminders (notifications, alerts)
- Delayed operations (rate limiting reset)
- Periodic tasks (health checks, sync)
---
Set Alarm
storage.setAlarm(time)
// Fire in 10 seconds
await this.ctx.storage.setAlarm(Date.now() + 10000);
// Fire at specific date/time
await this.ctx.storage.setAlarm(new Date('2025-12-31T23:59:59Z'));
// Fire in 1 hour
await this.ctx.storage.setAlarm(Date.now() + 3600000);Parameters:
time(number | Date): Unix timestamp (ms) or Date object
Behavior:
- ✅ Only ONE alarm per DO - setting new alarm overwrites previous
- ✅ Persists across hibernation - survives DO eviction
- ✅ Guaranteed at-least-once execution
---
Alarm Handler
alarm(alarmInfo)
Called when alarm fires (or retries).
async alarm(alarmInfo: { retryCount: number; isRetry: boolean }): Promise<void> {
console.log(`Alarm fired (retry: ${alarmInfo.isRetry}, count: ${alarmInfo.retryCount})`);
// Do work
await this.processBatch();
// Alarm is automatically deleted after successful execution
}Parameters:
alarmInfo.retryCount(number): Number of retries (0 on first attempt)alarmInfo.isRetry(boolean): True if this is a retry
CRITICAL:
- ✅ Implement idempotent operations (safe to retry)
- ✅ Limit retry attempts (avoid infinite retries)
- ❌ Don't throw errors lightly (triggers automatic retry)
---
Get Alarm
storage.getAlarm()
Get current alarm time (null if not set).
const alarmTime = await this.ctx.storage.getAlarm();
if (alarmTime === null) {
// No alarm set
await this.ctx.storage.setAlarm(Date.now() + 60000);
} else {
console.log(`Alarm scheduled for ${new Date(alarmTime).toISOString()}`);
}Returns: Promise<number | null> (Unix timestamp in ms)
---
Delete Alarm
storage.deleteAlarm()
Cancel scheduled alarm.
await this.ctx.storage.deleteAlarm();When to use:
- Cancel scheduled task
- Before deleting DO (if using
deleteAll())
---
Retry Behavior
Automatic Retries:
- Up to 6 retries on failure
- Exponential backoff: 2s, 4s, 8s, 16s, 32s, 64s
- Retries if
alarm()throws uncaught exception
Example with retry limit:
async alarm(alarmInfo: { retryCount: number; isRetry: boolean }): Promise<void> {
if (alarmInfo.retryCount > 3) {
console.error('Alarm failed after 3 retries, giving up');
// Clean up to avoid infinite retries
return;
}
try {
await this.sendNotification();
} catch (error) {
console.error('Alarm failed:', error);
throw error; // Will trigger retry
}
}---
Common Patterns
Pattern 1: Batching
Accumulate items, process in bulk.
async addItem(item: string): Promise<void> {
this.buffer.push(item);
await this.ctx.storage.put('buffer', this.buffer);
// Schedule alarm if not already set
const alarm = await this.ctx.storage.getAlarm();
if (alarm === null) {
await this.ctx.storage.setAlarm(Date.now() + 10000); // 10s
}
}
async alarm(): Promise<void> {
this.buffer = await this.ctx.storage.get('buffer') || [];
if (this.buffer.length > 0) {
await this.processBatch(this.buffer);
this.buffer = [];
await this.ctx.storage.put('buffer', []);
}
// Alarm automatically deleted after success
}Pattern 2: Periodic Cleanup
Run cleanup every hour.
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Schedule first cleanup
ctx.blockConcurrencyWhile(async () => {
const alarm = await ctx.storage.getAlarm();
if (alarm === null) {
await ctx.storage.setAlarm(Date.now() + 3600000); // 1 hour
}
});
}
async alarm(): Promise<void> {
// Cleanup old data
await this.cleanup();
// Schedule next cleanup
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}Pattern 3: Delayed Operation
Execute task after delay.
async scheduleTask(task: string, delayMs: number): Promise<void> {
await this.ctx.storage.put('pendingTask', task);
await this.ctx.storage.setAlarm(Date.now() + delayMs);
}
async alarm(): Promise<void> {
const task = await this.ctx.storage.get('pendingTask');
if (task) {
await this.executeTask(task);
await this.ctx.storage.delete('pendingTask');
}
}Pattern 4: Reminder/Notification
One-time reminder.
async setReminder(message: string, fireAt: Date): Promise<void> {
await this.ctx.storage.put('reminder', { message, fireAt: fireAt.getTime() });
await this.ctx.storage.setAlarm(fireAt);
}
async alarm(): Promise<void> {
const reminder = await this.ctx.storage.get('reminder');
if (reminder) {
await this.sendNotification(reminder.message);
await this.ctx.storage.delete('reminder');
}
}---
Limitations
⚠️ One alarm per DO
- Setting new alarm overwrites previous
- Use storage to track multiple pending tasks
⚠️ No cron syntax
- Alarm is one-time (but can reschedule in handler)
- For periodic tasks, reschedule in
alarm()handler
⚠️ Minimum precision: ~1 second
- Don't expect millisecond precision
- Designed for longer delays (seconds to hours)
---
Best Practices
Idempotent Operations
// ✅ GOOD: Idempotent (safe to retry)
async alarm(): Promise<void> {
const messageId = await this.ctx.storage.get('messageId');
// Check if already sent (idempotent)
const sent = await this.checkIfSent(messageId);
if (sent) {
return;
}
await this.sendMessage(messageId);
await this.markAsSent(messageId);
}
// ❌ BAD: Not idempotent (duplicate sends on retry)
async alarm(): Promise<void> {
await this.sendMessage(); // Will send duplicate if retried
}Limit Retries
async alarm(info: { retryCount: number }): Promise<void> {
if (info.retryCount > 3) {
console.error('Giving up after 3 retries');
return;
}
// Try operation
await this.doWork();
}Clean Up Before deleteAll()
async destroy(): Promise<void> {
// Delete alarm first
await this.ctx.storage.deleteAlarm();
// Then delete all storage
await this.ctx.storage.deleteAll();
}---
Official Docs: https://developers.cloudflare.com/durable-objects/api/alarms/
Durable Objects Best Practices
Production patterns and optimization strategies.
---
Performance
Minimize Constructor Work
Heavy work in constructor delays request handling and hibernation wake-up.
// ✅ GOOD
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Minimal initialization
this.sessions = new Map();
// Load from storage with blockConcurrencyWhile
ctx.blockConcurrencyWhile(async () => {
this.data = await ctx.storage.get('data') || defaultData;
});
}
// ❌ BAD
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Expensive operations delay all requests
await this.loadMassiveDataset();
await this.computeComplexState();
}Use Indexes for SQL Queries
// Create indexes for frequently queried columns
this.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_user_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_created_at ON messages(created_at);
`);
// Use EXPLAIN QUERY PLAN to verify index usage
const plan = this.sql.exec('EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?', email);Batch Operations
// ✅ GOOD: Batch inserts
this.sql.exec(`INSERT INTO messages (text, user_id) VALUES ${rows.map(() => '(?, ?)').join(', ')}`, ...flatValues);
// ❌ BAD: Individual inserts
for (const row of rows) {
this.sql.exec('INSERT INTO messages (text, user_id) VALUES (?, ?)', row.text, row.userId);
}Use Transactions
// Atomic multi-step operations
this.ctx.storage.transactionSync(() => {
this.sql.exec('UPDATE users SET balance = balance - ? WHERE id = ?', amount, senderId);
this.sql.exec('UPDATE users SET balance = balance + ? WHERE id = ?', amount, receiverId);
this.sql.exec('INSERT INTO transactions ...');
});---
Cost Optimization
Use WebSocket Hibernation
// ✅ GOOD: Hibernates when idle (~90% cost savings)
this.ctx.acceptWebSocket(server);
// ❌ BAD: Never hibernates (high duration charges)
server.accept();Use Alarms, Not setTimeout
// ✅ GOOD: Allows hibernation
await this.ctx.storage.setAlarm(Date.now() + 60000);
// ❌ BAD: Prevents hibernation
setTimeout(() => this.doWork(), 60000);Minimize Storage Size
// Periodic cleanup with alarms
async alarm(): Promise<void> {
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
this.sql.exec('DELETE FROM messages WHERE created_at < ?', oneDayAgo);
// Schedule next cleanup
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}---
Reliability
Implement Idempotent Operations
// ✅ GOOD: Idempotent (safe to retry)
async processPayment(paymentId: string, amount: number): Promise<void> {
// Check if already processed
const existing = await this.ctx.storage.get(`payment:${paymentId}`);
if (existing) {
return; // Already processed
}
// Process payment
await this.chargeCustomer(amount);
// Mark as processed
await this.ctx.storage.put(`payment:${paymentId}`, { processed: true, amount });
}
// ❌ BAD: Not idempotent (duplicate charges on retry)
async processPayment(amount: number): Promise<void> {
await this.chargeCustomer(amount);
}Limit Alarm Retries
async alarm(info: { retryCount: number }): Promise<void> {
if (info.retryCount > 3) {
console.error('Giving up after 3 retries');
await this.logFailure();
return;
}
await this.doWork();
}Graceful Error Handling
async processMessage(message: string): Promise<void> {
try {
await this.handleMessage(message);
} catch (error) {
console.error('Message processing failed:', error);
// Store failed message for retry
await this.ctx.storage.put(`failed:${Date.now()}`, message);
// Don't throw - prevents retry storm
}
}---
Security
Validate Input
async createUser(email: string, username: string): Promise<void> {
// Validate input
if (!email || !email.includes('@')) {
throw new Error('Invalid email');
}
if (!username || username.length < 3) {
throw new Error('Invalid username');
}
// Use parameterized queries (prevents SQL injection)
this.sql.exec(
'INSERT INTO users (email, username) VALUES (?, ?)',
email,
username
);
}Use Parameterized Queries
// ✅ GOOD: Parameterized (safe from SQL injection)
this.sql.exec('SELECT * FROM users WHERE email = ?', userEmail);
// ❌ BAD: String concatenation (SQL injection risk)
this.sql.exec(`SELECT * FROM users WHERE email = '${userEmail}'`);Authenticate Requests
async fetch(request: Request): Promise<Response> {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !this.validateToken(authHeader)) {
return new Response('Unauthorized', { status: 401 });
}
// Handle authenticated request
}---
Data Management
Monitor Storage Size
async getStorageSize(): Promise<number> {
// Approximate size (sum of all values)
const map = await this.ctx.storage.list();
let size = 0;
for (const value of map.values()) {
size += JSON.stringify(value).length;
}
return size;
}
async checkStorageLimit(): Promise<void> {
const size = await this.getStorageSize();
if (size > 900_000_000) { // 900MB (90% of 1GB limit)
console.warn('Storage approaching limit');
await this.triggerCleanup();
}
}Cleanup Old Data
// Regular cleanup with alarms
async alarm(): Promise<void> {
const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000); // 30 days
this.sql.exec('DELETE FROM messages WHERE created_at < ?', cutoff);
// Schedule next cleanup
await this.ctx.storage.setAlarm(Date.now() + 86400000); // 24 hours
}Backup Critical Data
async backup(): Promise<void> {
// Export to R2 or D1
const data = await this.exportData();
await this.env.BUCKET.put(`backup-${Date.now()}.json`, JSON.stringify(data));
}---
Testing
Local Development
# Start local dev server
npx wrangler dev
# Test with curl
curl -X POST http://localhost:8787/api/incrementIntegration Tests
// Test DO behavior
describe('Counter DO', () => {
it('should increment', async () => {
const stub = env.COUNTER.getByName('test-counter');
const count1 = await stub.increment();
expect(count1).toBe(1);
const count2 = await stub.increment();
expect(count2).toBe(2);
});
});Simulate Hibernation
// Test hibernation wake-up
constructor(ctx, env) {
super(ctx, env);
console.log('DO woke up!', {
websockets: ctx.getWebSockets().length,
});
// Restore state
ctx.getWebSockets().forEach(ws => {
const metadata = ws.deserializeAttachment();
this.sessions.set(ws, metadata);
});
}---
Monitoring
Log Important Events
async importantOperation(): Promise<void> {
console.log('Starting important operation', {
doId: this.ctx.id.toString(),
timestamp: Date.now(),
});
await this.doWork();
console.log('Important operation completed');
}Track Metrics
async recordMetric(metric: string, value: number): Promise<void> {
// Store metrics
await this.ctx.storage.put(`metric:${metric}:${Date.now()}`, value);
// Or send to Analytics Engine
// await this.env.ANALYTICS.writeDataPoint({
// indexes: [metric],
// doubles: [value],
// });
}Use Tail Logs
# Tail live logs
npx wrangler tail
# Filter by DO
npx wrangler tail --search "DurableObject"---
Common Patterns
Rate Limiting
async checkRateLimit(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(t => now - t < window);
if (validRequests.length >= limit) {
return false; // Rate limited
}
validRequests.push(now);
await this.ctx.storage.put(key, validRequests);
return true;
}Leader Election
async electLeader(workerId: string): Promise<boolean> {
try {
this.sql.exec(
'INSERT INTO leader (id, worker_id) VALUES (1, ?)',
workerId
);
return true; // Became leader
} catch {
return false; // Someone else is leader
}
}Session Management
See templates/state-api-patterns.ts for complete example.
---
Official Docs: https://developers.cloudflare.com/durable-objects/best-practices/
Common Durable Objects Patterns
Production-tested patterns for common use cases with Cloudflare Durable Objects.
---
Table of Contents
1. Pattern 1: Rate Limiting (Per-User) 2. Pattern 2: Session Management 3. Pattern 3: Leader Election 4. Pattern 4: Multi-DO Coordination
---
Pattern 1: Rate Limiting (Per-User)
Use case: Prevent abuse by limiting requests per user/IP/key
Why Durable Objects: Global uniqueness ensures accurate counting across all Cloudflare edges
export class RateLimiter extends DurableObject {
async checkLimit(userId: string, limit: number, window: number): Promise<boolean> {
const key = `rate:${userId}`;
const now = Date.now();
// Get recent requests
const requests = await this.ctx.storage.get<number[]>(key) || [];
// Remove requests outside window
const validRequests = requests.filter(timestamp => now - timestamp < window);
// Check limit
if (validRequests.length >= limit) {
return false; // Rate limit exceeded
}
// Add current request
validRequests.push(now);
await this.ctx.storage.put(key, validRequests);
return true; // Within limit
}
}
// Worker usage:
const limiter = env.RATE_LIMITER.getByName(userId);
const allowed = await limiter.checkLimit(userId, 100, 60000); // 100 req/min
if (!allowed) {
return new Response('Rate limit exceeded', { status: 429 });
}Key points:
- One DO instance per user/key (use
getByName(userId)) - Sliding window algorithm
- KV storage for simplicity
- Can extend with multiple limits (per second, minute, hour)
Production enhancements:
export class AdvancedRateLimiter extends DurableObject {
sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
// Create table for multiple rate limits
this.sql.exec(`
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window_type TEXT NOT NULL, -- 'second', 'minute', 'hour', 'day'
timestamp INTEGER NOT NULL,
PRIMARY KEY (key, window_type, timestamp)
)
`);
}
async checkMultipleLimits(
key: string,
limits: { second?: number; minute?: number; hour?: number }
): Promise<{ allowed: boolean; retryAfter?: number }> {
const now = Date.now();
// Check each limit
for (const [windowType, limit] of Object.entries(limits)) {
const windowMs = this.getWindowMs(windowType);
const cutoff = now - windowMs;
// Count requests in window
const cursor = this.sql.exec(
'SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND window_type = ? AND timestamp > ?',
key,
windowType,
cutoff
);
const { count } = cursor.one<{ count: number }>();
if (count >= limit) {
// Calculate retry-after
const oldestInWindow = this.sql.exec(
'SELECT MIN(timestamp) as oldest FROM rate_limits WHERE key = ? AND window_type = ?',
key,
windowType
).one<{ oldest: number }>();
const retryAfter = Math.ceil((oldestInWindow.oldest + windowMs - now) / 1000);
return { allowed: false, retryAfter };
}
}
// Record request for all window types
for (const windowType of Object.keys(limits)) {
this.sql.exec(
'INSERT INTO rate_limits (key, window_type, timestamp) VALUES (?, ?, ?)',
key,
windowType,
now
);
}
// Cleanup old entries
this.sql.exec(
'DELETE FROM rate_limits WHERE timestamp < ?',
now - (24 * 60 * 60 * 1000) // Keep last 24 hours
);
return { allowed: true };
}
private getWindowMs(windowType: string): number {
const windows = {
'second': 1000,
'minute': 60000,
'hour': 3600000,
'day': 86400000
};
return windows[windowType] || 60000;
}
}
// Usage: Multiple rate limits
const limiter = env.RATE_LIMITER.getByName(`user:${userId}`);
const result = await limiter.checkMultipleLimits(`api:${endpoint}`, {
second: 10, // 10 requests per second
minute: 100, // 100 requests per minute
hour: 1000 // 1000 requests per hour
});
if (!result.allowed) {
return new Response('Rate limit exceeded', {
status: 429,
headers: { 'Retry-After': String(result.retryAfter) }
});
}---
Pattern 2: Session Management
Use case: Store user session data with TTL and automatic cleanup
Why Durable Objects: Strong consistency for session data, built-in alarm for cleanup
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
);
`);
// Schedule cleanup alarm
ctx.blockConcurrencyWhile(async () => {
const alarm = await ctx.storage.getAlarm();
if (alarm === null) {
await ctx.storage.setAlarm(Date.now() + 3600000); // 1 hour
}
});
}
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;
}
// Check expiration
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 delete(key: string): Promise<void> {
this.sql.exec('DELETE FROM session WHERE key = ?', key);
}
async alarm(): Promise<void> {
// Cleanup expired sessions
this.sql.exec('DELETE FROM session WHERE expires_at < ?', Date.now());
// Schedule next cleanup
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}
}
// Worker usage:
const session = env.USER_SESSION.getByName(`user:${userId}`);
// Set session data with 24-hour TTL
await session.set('cart', { items: [...] }, 86400000);
// Get session data
const cart = await session.get('cart');Key points:
- One DO instance per user (use
getByName(userId)) - SQLite for structured data
- Automatic cleanup with alarms (reduces storage costs)
- TTL per key
---
Pattern 3: Leader Election
Use case: Ensure only one worker/instance performs a task (e.g., cron job, data sync)
Why Durable Objects: Global uniqueness guarantees single leader
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,
heartbeat_at INTEGER NOT NULL
);
`);
}
async electLeader(workerId: string, ttl: number = 60000): Promise<boolean> {
const now = Date.now();
// Try to become leader
try {
this.sql.exec(
'INSERT INTO leader (id, worker_id, elected_at, heartbeat_at) VALUES (1, ?, ?, ?)',
workerId,
now,
now
);
return true; // Became leader
} catch (error) {
// Check if current leader is expired
const cursor = this.sql.exec('SELECT worker_id, heartbeat_at FROM leader WHERE id = 1');
const row = cursor.one<{ worker_id: string; heartbeat_at: number }>();
if (now - row.heartbeat_at > ttl) {
// Current leader expired, replace it
this.sql.exec(
'UPDATE leader SET worker_id = ?, elected_at = ?, heartbeat_at = ? WHERE id = 1',
workerId,
now,
now
);
return true; // Became leader
}
return false; // Someone else is leader
}
}
async heartbeat(workerId: string): Promise<boolean> {
const cursor = this.sql.exec('SELECT worker_id FROM leader WHERE id = 1');
const row = cursor.one<{ worker_id: string }>({ allowNone: true });
if (row?.worker_id === workerId) {
this.sql.exec('UPDATE leader SET heartbeat_at = ? WHERE id = 1', Date.now());
return true; // Still leader
}
return false; // Not leader or leadership lost
}
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);
}
}
// Worker usage (in scheduled handler):
export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const workerId = crypto.randomUUID(); // Unique per worker instance
// Try to become leader
const election = env.LEADER_ELECTION.getByName('global');
const isLeader = await election.electLeader(workerId, 60000); // 60s TTL
if (!isLeader) {
console.log('Not leader, skipping task');
return;
}
console.log('I am the leader, executing task');
try {
// Perform work
await performCriticalTask(env);
// Send heartbeat every 30 seconds during work
ctx.waitUntil(
(async () => {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 30000));
await election.heartbeat(workerId);
}
})()
);
} finally {
// Release leadership when done
await election.releaseLeadership(workerId);
}
}
};Key points:
- Global singleton DO (use
getByName('global')) - TTL-based leadership expiration
- Heartbeat mechanism for long-running tasks
- Graceful leadership release
---
Pattern 4: Multi-DO Coordination
Use case: Multiple DO types working together (e.g., game coordinator + game rooms)
Why Durable Objects: Each DO type handles different concerns, coordinator orchestrates
// Coordinator DO
export class GameCoordinator 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 games (
game_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
player_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'waiting'
);
`);
}
async createGame(gameId: string, env: Env): Promise<void> {
// Create game room DO
const gameRoom = env.GAME_ROOM.getByName(gameId);
await gameRoom.initialize();
// Track in coordinator
this.sql.exec(
'INSERT INTO games (game_id, created_at) VALUES (?, ?)',
gameId,
Date.now()
);
}
async listGames(): Promise<Array<{ game_id: string; player_count: number; status: string }>> {
const cursor = this.sql.exec('SELECT game_id, player_count, status FROM games WHERE status = ?', 'waiting');
return cursor.toArray<{ game_id: string; player_count: number; status: string }>();
}
async updateGameStatus(gameId: string, status: string, playerCount: number): Promise<void> {
this.sql.exec(
'UPDATE games SET status = ?, player_count = ? WHERE game_id = ?',
status,
playerCount,
gameId
);
}
async deleteGame(gameId: string): Promise<void> {
this.sql.exec('DELETE FROM games WHERE game_id = ?', gameId);
}
}
// Game room DO
export class GameRoom 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 players (
player_id TEXT PRIMARY KEY,
joined_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS game_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
}
async initialize(): Promise<void> {
this.sql.exec(
"INSERT OR IGNORE INTO game_state (key, value) VALUES ('started', 'false')"
);
}
async addPlayer(playerId: string, env: Env): Promise<number> {
this.sql.exec(
'INSERT OR IGNORE INTO players (player_id, joined_at) VALUES (?, ?)',
playerId,
Date.now()
);
// Get player count
const cursor = this.sql.exec('SELECT COUNT(*) as count FROM players');
const { count } = cursor.one<{ count: number }>();
// Notify coordinator
const coordinator = env.GAME_COORDINATOR.getByName('global');
await coordinator.updateGameStatus(this.ctx.id.toString(), 'waiting', count);
return count;
}
async removePlayer(playerId: string, env: Env): Promise<number> {
this.sql.exec('DELETE FROM players WHERE player_id = ?', playerId);
// Get remaining player count
const cursor = this.sql.exec('SELECT COUNT(*) as count FROM players');
const { count } = cursor.one<{ count: number }>();
if (count === 0) {
// Notify coordinator to delete game
const coordinator = env.GAME_COORDINATOR.getByName('global');
await coordinator.deleteGame(this.ctx.id.toString());
} else {
// Update coordinator
const coordinator = env.GAME_COORDINATOR.getByName('global');
await coordinator.updateGameStatus(this.ctx.id.toString(), 'in_progress', count);
}
return count;
}
async startGame(): Promise<void> {
this.sql.exec(
"UPDATE game_state SET value = 'true' WHERE key = 'started'"
);
}
async getPlayers(): Promise<string[]> {
const cursor = this.sql.exec('SELECT player_id FROM players');
return cursor.toArray<{ player_id: string }>().map(row => row.player_id);
}
}
// Worker usage:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Create new game
if (url.pathname === '/games/create') {
const gameId = crypto.randomUUID();
const coordinator = env.GAME_COORDINATOR.getByName('global');
await coordinator.createGame(gameId, env);
return Response.json({ gameId });
}
// List available games
if (url.pathname === '/games') {
const coordinator = env.GAME_COORDINATOR.getByName('global');
const games = await coordinator.listGames();
return Response.json({ games });
}
// Join game
if (url.pathname === '/games/join') {
const { gameId, playerId } = await request.json();
const gameRoom = env.GAME_ROOM.getByName(gameId);
const playerCount = await gameRoom.addPlayer(playerId, env);
return Response.json({ playerCount });
}
return new Response('Not found', { status: 404 });
}
};Key points:
- Coordinator DO manages game list (global singleton)
- Game Room DOs handle individual game state (one per game)
- DOs communicate via stubs (not direct calls)
- Coordinator tracks high-level state, rooms manage details
---
Best Practices Across All Patterns
1. Use Appropriate ID Methods
// ✅ Named DOs for deterministic routing
const rateLimiter = env.LIMITER.getByName(userId);
const session = env.SESSION.getByName(`user:${userId}`);
// ✅ Global singletons for coordination
const coordinator = env.COORDINATOR.getByName('global');
const election = env.ELECTION.getByName('global');2. Handle Errors Gracefully
try {
const result = await doStub.method();
} catch (error) {
console.error('DO call failed:', error);
// Fallback logic
}3. Use SQL for Structured Data
// ✅ SQL for relational data
this.sql.exec('SELECT * FROM users WHERE status = ?', 'active');
// ✅ KV for simple key-value
await this.ctx.storage.put('counter', 42);4. Leverage Alarms for Cleanup
async alarm(): Promise<void> {
// Cleanup old data
this.sql.exec('DELETE FROM sessions WHERE expires_at < ?', Date.now());
// Reschedule
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}5. Minimize Constructor Work
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// ✅ Lightweight: Initialize SQL reference
this.sql = ctx.storage.sql;
// ❌ Heavy: Don't load data in constructor
// Use blockConcurrencyWhile if needed:
ctx.blockConcurrencyWhile(async () => {
await this.loadInitialState();
});
}---
Source: https://developers.cloudflare.com/durable-objects/examples/ Last Updated: 2025-11-23
Data Modeling for Durable Objects
Status: Production Ready ✅ Last Verified: 2025-12-27 Official Docs: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/
Overview
Comprehensive guide to SQL schema design, indexing, and data modeling patterns for Durable Objects with SQLite storage.
Storage Limits:
- Maximum 1GB per Durable Object instance
- SQL API uses SQLite backend
- ACID transactions supported
- Full SQL features (JOINs, indexes, triggers, etc.)
---
Schema Design Patterns
Single-Table Pattern
Best for simple use cases with minimal relationships:
export class Counter extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS counters (
name TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
`);
// Create index for timestamp queries
ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_counters_updated
ON counters(updated_at DESC)
`);
});
}
}Multi-Table Normalized Pattern
For complex relationships:
export class ChatRoom extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
// Users table
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
joined_at INTEGER NOT NULL,
last_active INTEGER NOT NULL
)
`);
// Messages table with foreign key
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
text TEXT NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Indexes
ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
ON messages(timestamp DESC)
`);
ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_user
ON messages(user_id, timestamp DESC)
`);
});
}
}Denormalized Pattern for Performance
Trade storage for query speed:
export class Analytics extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
// Denormalized: Store user data with each event
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_name TEXT NOT NULL, -- Denormalized
user_email TEXT NOT NULL, -- Denormalized
event_type TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT
)
`);
// Single index covers common queries
ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_events_user_timestamp
ON events(user_id, timestamp DESC)
`);
});
}
}---
Index Strategies
Covering Indexes
Create indexes that include all columns needed by queries:
// Query: SELECT user_id, text FROM messages WHERE timestamp > ? ORDER BY timestamp
ctx.storage.sql.exec(`
CREATE INDEX idx_messages_timestamp_covering
ON messages(timestamp DESC, user_id, text)
`);
// This query uses index-only scan (faster)
const recent = ctx.storage.sql.exec(`
SELECT user_id, text
FROM messages
WHERE timestamp > ?
ORDER BY timestamp DESC
LIMIT 100
`, cutoffTime).toArray();Composite Indexes for Multi-Column Queries
// Bad: Separate indexes
ctx.storage.sql.exec(`CREATE INDEX idx_user ON events(user_id)`);
ctx.storage.sql.exec(`CREATE INDEX idx_type ON events(event_type)`);
// Good: Composite index
ctx.storage.sql.exec(`
CREATE INDEX idx_user_type_timestamp
ON events(user_id, event_type, timestamp DESC)
`);
// Efficient query using composite index
const userEvents = ctx.storage.sql.exec(`
SELECT * FROM events
WHERE user_id = ? AND event_type = ?
ORDER BY timestamp DESC
`, userId, eventType).toArray();Partial Indexes for Filtered Queries
// Only index active users
ctx.storage.sql.exec(`
CREATE INDEX idx_active_users
ON users(last_active DESC)
WHERE active = 1
`);
// Only index recent messages
ctx.storage.sql.exec(`
CREATE INDEX idx_recent_messages
ON messages(timestamp DESC)
WHERE timestamp > 1704067200000 -- After 2024-01-01
`);---
Transaction Patterns
Basic Transaction
async updateBalance(userId: string, amount: number): Promise<void> {
this.ctx.storage.sql.exec("BEGIN");
try {
// Get current balance
const result = this.ctx.storage.sql.exec(
"SELECT balance FROM accounts WHERE user_id = ?",
userId
).one<{ balance: number }>();
const newBalance = (result?.balance ?? 0) + amount;
if (newBalance < 0) {
throw new Error("Insufficient balance");
}
// Update balance
this.ctx.storage.sql.exec(
"UPDATE accounts SET balance = ? WHERE user_id = ?",
newBalance,
userId
);
// Log transaction
this.ctx.storage.sql.exec(
"INSERT INTO transactions (user_id, amount, timestamp) VALUES (?, ?, ?)",
userId,
amount,
Date.now()
);
this.ctx.storage.sql.exec("COMMIT");
} catch (error) {
this.ctx.storage.sql.exec("ROLLBACK");
throw error;
}
}Optimistic Locking with Versions
async updateDocument(docId: string, content: string): Promise<void> {
this.ctx.storage.sql.exec("BEGIN");
try {
// Get current version
const doc = this.ctx.storage.sql.exec(
"SELECT version FROM documents WHERE id = ?",
docId
).one<{ version: number }>();
if (!doc) {
throw new Error("Document not found");
}
const newVersion = doc.version + 1;
// Update with version check
const result = this.ctx.storage.sql.exec(
"UPDATE documents SET content = ?, version = ? WHERE id = ? AND version = ?",
content,
newVersion,
docId,
doc.version
);
if (result.changes === 0) {
throw new Error("Document was modified by another process");
}
this.ctx.storage.sql.exec("COMMIT");
} catch (error) {
this.ctx.storage.sql.exec("ROLLBACK");
throw error;
}
}---
State Size Optimization
Pagination to Manage Large Datasets
async getMessagesPaginated(
limit: number = 100,
cursor?: number
): Promise<{ messages: any[]; nextCursor?: number }> {
const messages = this.ctx.storage.sql.exec(`
SELECT id, user_id, text, timestamp
FROM messages
WHERE timestamp < ?
ORDER BY timestamp DESC
LIMIT ?
`, cursor ?? Date.now(), limit + 1).toArray();
const hasMore = messages.length > limit;
const results = hasMore ? messages.slice(0, limit) : messages;
return {
messages: results,
nextCursor: hasMore ? (results[results.length - 1] as any).timestamp : undefined,
};
}Archiving Old Data
async archiveOldMessages(cutoffDays: number): Promise<number> {
const cutoffTime = Date.now() - (cutoffDays * 24 * 60 * 60 * 1000);
// Copy to archive table
this.ctx.storage.sql.exec(`
INSERT INTO messages_archive
SELECT * FROM messages
WHERE timestamp < ?
`, cutoffTime);
// Delete from main table
const result = this.ctx.storage.sql.exec(`
DELETE FROM messages
WHERE timestamp < ?
`, cutoffTime);
return result.changes;
}Data Compression
async storeCompressedData(key: string, data: any): Promise<void> {
const json = JSON.stringify(data);
// Simple compression: store as base64 if large
const compressed = json.length > 1000
? btoa(json) // In real app, use actual compression
: json;
const isCompressed = compressed.length < json.length;
this.ctx.storage.sql.exec(`
INSERT OR REPLACE INTO data (key, value, compressed)
VALUES (?, ?, ?)
`, key, compressed, isCompressed ? 1 : 0);
}---
TTL Patterns with Alarms
Automatic Expiration
export class CacheDO extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
ctx.storage.sql.exec(`
CREATE INDEX idx_cache_expires
ON cache(expires_at)
`);
// Schedule next cleanup
this.scheduleCleanup();
});
}
async set(key: string, value: any, ttlSeconds: number): Promise<void> {
const expiresAt = Date.now() + (ttlSeconds * 1000);
this.ctx.storage.sql.exec(`
INSERT OR REPLACE INTO cache (key, value, expires_at)
VALUES (?, ?, ?)
`, key, JSON.stringify(value), expiresAt);
}
async get(key: string): Promise<any | null> {
const result = this.ctx.storage.sql.exec(`
SELECT value FROM cache
WHERE key = ? AND expires_at > ?
`, key, Date.now()).one<{ value: string }>();
return result ? JSON.parse(result.value) : null;
}
private async scheduleCleanup(): Promise<void> {
// Run cleanup every hour
await this.ctx.storage.setAlarm(Date.now() + 3600_000);
}
async alarm(): Promise<void> {
// Delete expired entries
const result = this.ctx.storage.sql.exec(`
DELETE FROM cache WHERE expires_at < ?
`, Date.now());
console.log(`Cleaned up ${result.changes} expired entries`);
// Schedule next cleanup
await this.scheduleCleanup();
}
}TTL with Grace Period
async set(key: string, value: any, ttlSeconds: number): Promise<void> {
const expiresAt = Date.now() + (ttlSeconds * 1000);
const gracePeriodEnds = expiresAt + (300 * 1000); // +5 minutes
this.ctx.storage.sql.exec(`
INSERT OR REPLACE INTO cache (key, value, expires_at, grace_period_ends)
VALUES (?, ?, ?, ?)
`, key, JSON.stringify(value), expiresAt, gracePeriodEnds);
}
async get(key: string, allowGracePeriod: boolean = false): Promise<any | null> {
const now = Date.now();
const query = allowGracePeriod
? `SELECT value, expires_at < ? as expired FROM cache WHERE key = ? AND grace_period_ends > ?`
: `SELECT value FROM cache WHERE key = ? AND expires_at > ?`;
const result = this.ctx.storage.sql.exec(
query,
...(allowGracePeriod ? [now, key, now] : [key, now])
).one();
if (!result) return null;
if (allowGracePeriod && (result as any).expired) {
// Trigger background refresh
this.ctx.waitUntil(this.refreshKey(key));
}
return JSON.parse((result as any).value);
}---
Cursor-Based Pagination
Efficient Pagination Pattern
interface PaginationOptions {
limit?: number;
cursor?: string;
direction?: 'forward' | 'backward';
}
async getMessages(options: PaginationOptions = {}): Promise<{
messages: any[];
nextCursor?: string;
prevCursor?: string;
}> {
const limit = options.limit ?? 50;
const direction = options.direction ?? 'forward';
let query: string;
let params: any[];
if (!options.cursor) {
// First page
query = `
SELECT id, user_id, text, timestamp
FROM messages
ORDER BY timestamp DESC
LIMIT ?
`;
params = [limit + 1];
} else {
const cursorTimestamp = parseInt(options.cursor, 10);
if (direction === 'forward') {
query = `
SELECT id, user_id, text, timestamp
FROM messages
WHERE timestamp < ?
ORDER BY timestamp DESC
LIMIT ?
`;
params = [cursorTimestamp, limit + 1];
} else {
query = `
SELECT id, user_id, text, timestamp
FROM messages
WHERE timestamp > ?
ORDER BY timestamp ASC
LIMIT ?
`;
params = [cursorTimestamp, limit + 1];
}
}
const results = this.ctx.storage.sql.exec(query, ...params).toArray();
const hasMore = results.length > limit;
const messages = hasMore ? results.slice(0, limit) : results;
if (direction === 'backward') {
messages.reverse();
}
return {
messages,
nextCursor: hasMore && messages.length > 0
? (messages[messages.length - 1] as any).timestamp.toString()
: undefined,
prevCursor: messages.length > 0
? (messages[0] as any).timestamp.toString()
: undefined,
};
}---
Schema Migration Patterns
Versioned Migrations
export class VersionedDO extends DurableObject {
private readonly CURRENT_VERSION = 3;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
await this.runMigrations();
});
}
private async runMigrations(): Promise<void> {
// Get current version
const versionResult = this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`);
const currentVersion = this.ctx.storage.sql.exec(`
SELECT MAX(version) as version FROM schema_version
`).one<{ version: number }>();
const version = currentVersion?.version ?? 0;
// Run migrations sequentially
if (version < 1) {
await this.migration_v1();
}
if (version < 2) {
await this.migration_v2();
}
if (version < 3) {
await this.migration_v3();
}
}
private async migration_v1(): Promise<void> {
this.ctx.storage.sql.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)
`);
this.ctx.storage.sql.exec(`
INSERT INTO schema_version (version, applied_at)
VALUES (1, ?)
`, Date.now());
}
private async migration_v2(): Promise<void> {
// Add email column
this.ctx.storage.sql.exec(`
ALTER TABLE users ADD COLUMN email TEXT
`);
this.ctx.storage.sql.exec(`
INSERT INTO schema_version (version, applied_at)
VALUES (2, ?)
`, Date.now());
}
private async migration_v3(): Promise<void> {
// Add index on email
this.ctx.storage.sql.exec(`
CREATE INDEX idx_users_email ON users(email)
`);
this.ctx.storage.sql.exec(`
INSERT INTO schema_version (version, applied_at)
VALUES (3, ?)
`, Date.now());
}
}---
Common Anti-Patterns to Avoid
❌ Anti-Pattern 1: No Indexes
// BAD: No index on frequently queried column
ctx.storage.sql.exec(`
CREATE TABLE messages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
`);
// Query will be slow
const messages = ctx.storage.sql.exec(`
SELECT * FROM messages WHERE user_id = ?
`, userId).toArray();
// GOOD: Add index
ctx.storage.sql.exec(`
CREATE INDEX idx_messages_user ON messages(user_id)
`);❌ Anti-Pattern 2: SELECT * When Not Needed
// BAD: Fetches all columns
const messages = ctx.storage.sql.exec(`
SELECT * FROM messages WHERE user_id = ?
`, userId).toArray();
// GOOD: Fetch only needed columns
const messages = ctx.storage.sql.exec(`
SELECT id, text, timestamp FROM messages WHERE user_id = ?
`, userId).toArray();❌ Anti-Pattern 3: N+1 Queries
// BAD: N+1 query pattern
const users = ctx.storage.sql.exec(`SELECT id FROM users`).toArray();
for (const user of users) {
const messages = ctx.storage.sql.exec(`
SELECT * FROM messages WHERE user_id = ?
`, (user as any).id).toArray();
// Process messages
}
// GOOD: Single JOIN query
const results = ctx.storage.sql.exec(`
SELECT u.id, u.name, m.id as message_id, m.text
FROM users u
LEFT JOIN messages m ON u.id = m.user_id
`).toArray();❌ Anti-Pattern 4: Unbounded Queries
// BAD: No LIMIT (could return millions of rows)
const messages = ctx.storage.sql.exec(`
SELECT * FROM messages ORDER BY timestamp DESC
`).toArray();
// GOOD: Always use LIMIT
const messages = ctx.storage.sql.exec(`
SELECT * FROM messages ORDER BY timestamp DESC LIMIT 100
`).toArray();---
Best Practices Summary
✅ DO:
- Create indexes for all foreign keys
- Use composite indexes for multi-column queries
- Implement pagination for large result sets
- Use transactions for multi-step updates
- Schedule alarms for TTL/cleanup
- Version your schema migrations
- Fetch only needed columns
- Use LIMIT on all queries
❌ DON'T:
- SELECT * without LIMIT
- Create indexes on every column
- Store large blobs in SQL (use R2/KV instead)
- Skip transaction rollback handling
- Forget to schedule next alarm
- Use unbounded WHERE IN clauses
- Nest transactions
---
Sources
Durable Objects Error Codes Catalog
Status: Production Ready ✅ Last Verified: 2025-12-27
Comprehensive catalog of Durable Objects errors with solutions.
---
Deployment Errors
E001: Class Not Found
Error: Durable Object binding 'COUNTER' class 'Counter' not found
Cause: Class not exported or wrong name
Solution:
// Ensure export at end of file
export { Counter };
// Or
export default Counter;E002: Missing Migration
Error: Durable Object class 'Counter' must be declared in migrations
Solution:
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
}
]
}E003: Binding Name Mismatch
Error: Cannot find binding 'COUNTER'
Cause: Binding name in wrangler.jsonc doesn't match code
Solution:
// wrangler.jsonc
{"bindings": [{"name": "COUNTER", ...}]}
// src/index.ts
env.COUNTER.idFromName(...) // Must match---
Runtime Errors
E004: Constructor Timeout
Error: Durable Object constructor exceeded CPU time limit
Cause: Too much work in blockConcurrencyWhile()
Solution:
// ❌ Bad
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
await this.loadHeavyData(); // Slow
});
}
// ✅ Good
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
await this.initSchema(); // Fast
});
}E005: SQL Syntax Error
Error: SQL error: near "'text'": syntax error
Cause: Using single quotes for identifiers
Solution:
// ❌ Bad
await this.ctx.storage.sql.exec(
"SELECT * FROM 'messages' WHERE 'user_id' = ?"
);
// ✅ Good
await this.ctx.storage.sql.exec(
'SELECT * FROM messages WHERE user_id = ?'
);
// Or double quotes for identifiers
await this.ctx.storage.sql.exec(
'SELECT * FROM "messages" WHERE "user_id" = ?'
);E006: Transaction Nesting
Error: Cannot start a transaction within a transaction
Cause: Nested BEGIN/COMMIT statements
Solution:
// ❌ Bad
await this.ctx.storage.sql.exec('BEGIN');
await this.someMethod(); // Also calls BEGIN
await this.ctx.storage.sql.exec('COMMIT');
// ✅ Good
await this.ctx.storage.sql.exec(`
BEGIN;
INSERT INTO ...;
UPDATE ...;
COMMIT;
`);---
WebSocket Errors
E007: Hibernation Blocked
Error: WebSocket connections not hibernating (high costs)
Cause: setTimeout or setInterval usage
Solution:
// ❌ Bad
setTimeout(() => this.cleanup(), 60000);
// ✅ Good
async alarm() {
await this.cleanup();
await this.ctx.storage.setAlarm(Date.now() + 60000);
}E008: WebSocket Accept Failed
Error: Cannot call acceptWebSocket() after response sent
Cause: Trying to accept WebSocket after returning response
Solution:
// ✅ Must accept before returning
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade') === 'websocket') {
const pair = new WebSocketPair();
this.ctx.acceptWebSocket(pair[1]);
return new Response(null, { status: 101, webSocket: pair[0] });
}
return new Response('Not Found', { status: 404 });
}---
Storage Errors
E009: Storage Limit Exceeded
Error: Durable Object storage limit exceeded (1GB for SQL, 128MB for KV)
Solution:
// Implement TTL cleanup
async alarm() {
await this.ctx.storage.sql.exec(
'DELETE FROM messages WHERE expires_at <= ?',
Date.now()
);
}
// Or partition data across multiple DOs
const shardId = hashUserId(userId) % 10;
const id = env.COUNTER.idFromName(`shard-${shardId}`);E010: deleteAll() Partial Completion (KV only)
Error: deleteAll() doesn't delete all keys in one operation
Cause: KV deleteAll() is not atomic
Solution:
// Use SQL backend for atomic operations
{
"migrations": [
{"tag": "v1", "new_sqlite_classes": ["MyDO"]}
]
}
// Or manually delete with SQL
await this.ctx.storage.sql.exec('DELETE FROM table_name');---
Alarm Errors
E011: Alarm Not Executing
Error: Alarm scheduled but not running
Cause: Missing alarm() method or incorrect scheduling
Solution:
// Must implement alarm() method
async alarm(): Promise<void> {
await this.doWork();
await this.ctx.storage.setAlarm(Date.now() + 60000);
}
// Schedule correctly
await this.ctx.storage.setAlarm(Date.now() + 60000); // ✅
// NOT: await this.ctx.storage.setAlarm(60000); // ❌ WrongE012: Alarm Retry Failures
Error: Alarm retries exhausted
Cause: alarm() method throwing errors repeatedly
Solution:
async alarm(): Promise<void> {
try {
await this.riskyOperation();
} catch (error) {
console.error('Alarm error:', error);
// Don't throw - prevents retry exhaustion
}
// Always reschedule
await this.ctx.storage.setAlarm(Date.now() + 60000);
}---
Migration Errors
E013: Migration Tag Conflict
Error: Migration tag 'v1' already exists
Cause: Duplicate migration tags
Solution:
{
"migrations": [
{"tag": "v1", ...},
{"tag": "v2", ...}, // ✅ Unique tags
{"tag": "v3", ...}
]
}E014: Cannot Modify Past Migration
Error: Cannot modify deployed migration
Cause: Trying to change already-deployed migration
Solution:
// ❌ Don't modify v1 after deployment
// ✅ Add new migration
{
"migrations": [
{"tag": "v1", "new_sqlite_classes": ["Counter"]},
{"tag": "v2", "new_sqlite_classes": ["ChatRoom"]} // New
]
}---
RPC Errors
E015: ctx.id.name Returns Empty String
Error: this.ctx.id.name returns "" inside DO methods
Cause: RPC calls don't preserve ID name
Solution: Use RpcTarget pattern
export class MyDORpc extends RpcTarget {
constructor(private mainDo: MyDO, private doName: string) {
super();
}
async method(): Promise<void> {
return this.mainDo.method(this.doName); // Pass name
}
}
export class MyDO extends DurableObject {
async fetch(request: Request): Promise<Response> {
const doName = this.ctx.id.toString();
return Response.json(new MyDORpc(this, doName));
}
}---
Performance Errors
E016: Slow Queries
Error: Queries taking >100ms
Cause: Missing indexes
Solution:
// Add indexes
await this.ctx.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_user_messages
ON messages(user_id, created_at DESC)
`);E017: Memory Exhaustion
Error: Out of memory
Cause: Unbounded cache growth
Solution:
// Implement LRU cache with max size
private cache = new Map();
private readonly MAX_CACHE = 1000;
set(key: string, value: any) {
if (this.cache.size >= this.MAX_CACHE) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}---
Testing Errors
E018: Vitest Pool Not Found
Error: Cannot find module '@cloudflare/vitest-pool-workers'
Solution:
npm install -D @cloudflare/vitest-pool-workersE019: DO State Not Isolated
Error: Tests interfering with each other
Cause: Not using unique DO IDs per test
Solution:
test('counter increments', async () => {
const id = env.COUNTER.idFromName(`test-${crypto.randomUUID()}`);
const stub = env.COUNTER.get(id);
// Test with isolated DO instance
});---
Quick Diagnostic
# Check logs
wrangler tail
# Test locally
wrangler dev
# Validate config
./scripts/validate-do-config.sh
# Run tests
npm test---
Last Updated: 2025-12-27
Gradual Deployments for Durable Objects
Status: Production Ready ✅ Last Verified: 2025-12-27 Official Docs: Gradual deployments
Overview
Gradual Deployments allow you to incrementally deploy new versions of Workers by splitting traffic across versions. This enables phased rollouts, A/B testing, and safer deployments by reducing the risk of immediate full transitions.
Key Benefits:
- Reduce deployment risk with incremental rollouts
- Monitor error rates across versions before full deployment
- Rollback quickly if issues arise
- Test production versions with real traffic before 100% rollout
Requirements:
- Wrangler 3.40.0+
- Workers Paid plan
---
Core Concepts
Traffic Splitting
Split traffic between Worker versions based on percentage:
Version 1 (stable): 90% of traffic
Version 2 (canary): 10% of trafficVersion Affinity
Requests from the same user/session consistently hit the same version to prevent version skew:
User A → Always Version 1
User B → Always Version 2
User C → Always Version 1Version Overrides
Force specific requests to a particular version for testing:
curl -H 'Cloudflare-Workers-Version-Overrides: my-worker="VERSION_ID"'---
How Gradual Deployments Work
1. Version Assignment
- Per-Request (Default): Each request is randomly routed based on percentage
- With Affinity: Requests with same
Cloudflare-Workers-Version-Keyheader hit same version
2. Durable Objects Behavior
CRITICAL: Durable Objects have special behavior during gradual deployments:
- Only one version of each DO runs at a time
- Each DO instance receives a fixed version when first accessed
- Version assignment persists until next deployment
- DO version is assigned based on configured percentages
- Migrations CANNOT be uploaded as versions (must be deployed directly)
3. Deployment Flow
1. Upload new version (npx wrangler versions upload)
2. Deploy with traffic split (npx wrangler versions deploy)
3. Monitor metrics and errors
4. Gradually increase percentage to new version
5. Deploy at 100% when stable---
Wrangler CLI Implementation
Step 1: Create Initial Worker
npm create cloudflare@latest my-worker
cd my-worker
npm install
npx wrangler deployStep 2: Make Changes and Upload Version
# Edit src/index.ts
npx wrangler versions uploadOutput:
Uploading Worker Version...
Worker Version ID: 8b0f8228-bb42-4cf2-9e35-a90386a8e9e3Step 3: Deploy with Traffic Split
npx wrangler versions deployInteractive Prompts:
? Select a version to deploy:
❯ 8b0f8228-bb42-4cf2-9e35-a90386a8e9e3 (New)
7f9a6bc1-aa31-4b0e-be73-5a77da8920c1 (Current 100%)
? What percentage of traffic should the new version receive?
❯ 10%
? What percentage should the current version receive?
❯ 90%Result:
Deployment successful!
New version: 10% traffic
Current version: 90% trafficStep 4: Monitor Metrics
# View analytics
npx wrangler tail
# Check specific version metrics in dashboardStep 5: Increase Traffic or Rollback
# Increase to 50%
npx wrangler versions deploy
# Or rollback to 100% on stable version
npx wrangler versions deploy --percentage 100 --version-id 7f9a6bc1-aa31-4b0e-be73-5a77da8920c1---
Dashboard Implementation
Step 1: Deploy Initial Worker
1. Navigate to Workers & Pages → Create 2. Select "Hello World" template 3. Deploy
Step 2: Create New Version
1. Click "Edit Code" 2. Make changes 3. Click "Save" (NOT "Save and Deploy")
- This creates a new version without deploying
Step 3: Configure Gradual Deployment
1. Navigate to "Deployments" tab 2. Click "Deploy Version" 3. Select new version from dropdown 4. Configure traffic split:
New version: 10%
Current version: 90%5. Click "Deploy"
Step 4: Monitor in Dashboard
1. Navigate to "Analytics" → "Workers" 2. Filter by version using "ScriptVersion" dimension 3. Compare error rates, latency, throughput across versions
Step 5: Adjust Traffic or Rollback
1. Navigate to "Deployments" 2. Click "Deploy Version" 3. Adjust percentages or select different version 4. Deploy
---
Version Affinity Implementation
Setting Version Key
Add header to ensure requests from same user/session hit same version:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Example 1: User-based affinity
const userId = request.headers.get("X-User-ID");
if (userId) {
request.headers.set("Cloudflare-Workers-Version-Key", userId);
}
// Example 2: Session-based affinity
const sessionId = url.searchParams.get("session");
if (sessionId) {
request.headers.set("Cloudflare-Workers-Version-Key", sessionId);
}
// Process request
return new Response("Hello!");
}
};Why Version Affinity Matters
Without Affinity (Problem):
Request 1 (User A) → Version 1 (loads asset v1)
Request 2 (User A) → Version 2 (404 - asset v1 doesn't exist)With Affinity (Solution):
Request 1 (User A, Key: user-123) → Version 1
Request 2 (User A, Key: user-123) → Version 1 (consistent)Use Cases:
- Static assets (prevent 404s)
- Session state (prevent version skew)
- WebSocket connections (maintain connection to same version)
- Multi-request workflows (checkout, auth flows)
---
Version Overrides for Testing
Testing Specific Version
Override version for testing before broader rollout:
# Test new version
curl -s https://my-worker.workers.dev \
-H 'Cloudflare-Workers-Version-Overrides: my-worker="8b0f8228-bb42-4cf2-9e35-a90386a8e9e3"'
# Test old version
curl -s https://my-worker.workers.dev \
-H 'Cloudflare-Workers-Version-Overrides: my-worker="7f9a6bc1-aa31-4b0e-be73-5a77da8920c1"'Multiple Workers Override
curl -s https://my-app.com \
-H 'Cloudflare-Workers-Version-Overrides: worker1="VERSION_1", worker2="VERSION_2"'Use Cases
- QA testing specific version in production
- Reproduce customer issues on specific version
- Validate fixes before increasing traffic
- Test version combinations (when multiple Workers involved)
---
Durable Objects Special Considerations
DO Version Assignment
// Each DO instance gets assigned a version when first accessed
const id = env.MY_DO.idFromName("user-123");
const stub = env.MY_DO.get(id);
// This DO will run on Version 1 or Version 2 based on percentage
// Assignment happens ONCE and persists until next deployment
await stub.someMethod();Migration Constraints
CRITICAL: Migrations CANNOT be uploaded as versions
# ❌ WRONG: Upload migration as version
npx wrangler versions upload
# Error: Cannot upload migrations as versions
# ✅ CORRECT: Deploy migrations directly
npx wrangler versions deploy --percentage 100Why: Migrations must be atomic across all DOs. Gradual rollouts would create inconsistent state.
Example: DO with Gradual Deployment
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
script_name = "my-worker"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Counter"]Deployment Process:
# 1. Deploy initial version with migration
npx wrangler deploy
# 2. Make code changes (NOT migration changes)
# Edit src/index.ts
# 3. Upload new version
npx wrangler versions upload
# 4. Deploy with gradual rollout
npx wrangler versions deploy
# New version: 10% traffic
# Old version: 90% traffic
# 5. If you need a NEW migration, deploy at 100%
# Edit migrations in wrangler.toml
npx wrangler versions deploy --percentage 100DO Version Persistence
Deployment 1: 10% v2, 90% v1
DO "user-123" → Assigned to v2 (based on 10% probability)
DO "user-456" → Assigned to v1 (based on 90% probability)
Deployment 2: 50% v2, 50% v1
DO "user-123" → STILL v2 (doesn't change)
DO "user-456" → STILL v1 (doesn't change)
DO "user-789" → NEW, assigned to v2 or v1 (50/50)
Deployment 3: 100% v2
DO "user-123" → v2 (already on v2)
DO "user-456" → v2 (migrated from v1)
DO "user-789" → v2 (all on v2 now)---
Monitoring & Observability
Logpush API
Enable ScriptVersion in Logpush requests to identify which version handled invocations:
{
"timestamp": "2025-12-27T10:00:00Z",
"ScriptVersion": "8b0f8228-bb42-4cf2-9e35-a90386a8e9e3",
"status": 200,
"duration": 45
}Version Metadata Binding
Access version ID inside Worker:
interface Env {
VERSION_METADATA: {
id: string;
tag: string;
timestamp: string;
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
console.log(`Running on version: ${env.VERSION_METADATA.id}`);
return new Response(
JSON.stringify({
version: env.VERSION_METADATA.id,
tag: env.VERSION_METADATA.tag,
})
);
}
};Dashboard Analytics
1. Navigate to Workers → Analytics 2. Filter by version:
Dimension: ScriptVersion
Values: [8b0f8228-bb42-4cf2-9e35-a90386a8e9e3, 7f9a6bc1-aa31-4b0e-be73-5a77da8920c1]3. Compare metrics:
- Error rate
- P50/P95/P99 latency
- Request volume
- CPU time
---
Best Practices
1. Start with Small Percentages
# Day 1: 5% canary
npx wrangler versions deploy --percentage 5
# Day 2: 10% if stable
npx wrangler versions deploy --percentage 10
# Day 3: 25% if stable
npx wrangler versions deploy --percentage 25
# Day 4: 50% if stable
npx wrangler versions deploy --percentage 50
# Day 5: 100% if stable
npx wrangler versions deploy --percentage 1002. Monitor Before Increasing
Key Metrics to Watch:
- Error rate (should not increase)
- P95 latency (should not increase)
- Request volume (should match expected split)
- DO alarm failures (for DO-heavy workloads)
Example Check:
Version 1 (90%): 0.1% error rate, 50ms P95
Version 2 (10%): 0.1% error rate, 52ms P95
✅ Safe to increase Version 2 to 25%
Version 2 (10%): 1.5% error rate, 150ms P95
❌ ROLLBACK - Version 2 has issues3. Use Version Affinity for Stateful Workloads
// For apps with static assets
request.headers.set("Cloudflare-Workers-Version-Key", userId);
// For WebSocket apps
request.headers.set("Cloudflare-Workers-Version-Key", connectionId);
// For multi-step workflows
request.headers.set("Cloudflare-Workers-Version-Key", sessionId);4. Test with Version Overrides First
# Test new version with override BEFORE gradual deployment
curl -H 'Cloudflare-Workers-Version-Overrides: my-worker="NEW_VERSION"' \
https://my-worker.workers.dev
# Run integration tests against new version
npm run test:integration -- --version="NEW_VERSION"
# Only deploy if tests pass
npx wrangler versions deploy --percentage 105. Have Rollback Plan Ready
# Save current stable version ID
STABLE_VERSION="7f9a6bc1-aa31-4b0e-be73-5a77da8920c1"
# Deploy canary
npx wrangler versions deploy --percentage 10
# If issues, rollback immediately
npx wrangler versions deploy --percentage 100 --version-id $STABLE_VERSION6. Deploy Migrations Separately
# ✅ GOOD: Separate migration deployments from gradual rollouts
# Step 1: Deploy migration at 100%
npx wrangler versions deploy --percentage 100 # Migration v2
# Step 2: Wait for migration to complete
# Step 3: Make code changes
# Step 4: Upload version
npx wrangler versions upload
# Step 5: Gradual rollout of code changes
npx wrangler versions deploy --percentage 10
# ❌ BAD: Trying to gradual rollout a migration
# This will fail!---
Rollback Procedures
Immediate Rollback
# Get list of versions
npx wrangler versions list
# Rollback to stable version
npx wrangler versions deploy \
--percentage 100 \
--version-id 7f9a6bc1-aa31-4b0e-be73-5a77da8920c1Gradual Rollback
# Reduce new version gradually
npx wrangler versions deploy --percentage 5 # Down from 10%
npx wrangler versions deploy --percentage 0 # Remove entirely
# Or directly to 100% stable
npx wrangler versions deploy --percentage 100 --version-id STABLE_VERSIONEmergency Rollback
# Skip prompts for emergency
npx wrangler versions deploy \
--percentage 100 \
--version-id STABLE_VERSION \
--yes---
Static Assets Considerations
Problem: HTML from Version 1 references assets that only exist in Version 1
Request 1: GET / → Version 1 → Returns HTML with <script src="/bundle-v1.js">
Request 2: GET /bundle-v1.js → Version 2 → 404 (asset doesn't exist in v2)Solution 1: Use Version Affinity
// Set version key based on session
const sessionId = request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1];
if (sessionId) {
request.headers.set("Cloudflare-Workers-Version-Key", sessionId);
}Solution 2: Version Assets with Hashes
// Version 1: <script src="/bundle.abc123.js">
// Version 2: <script src="/bundle.def456.js">
// Both assets exist in both versionsSolution 3: Serve Assets from External CDN
HTML: Worker Version 1/2
Assets: cdn.example.com (static, versioned independently)---
Sources
Durable Objects Migration Cheatsheet
Status: Production Ready ✅ Last Verified: 2025-12-27
Quick reference guide for Durable Objects migrations.
---
Migration Types
New Class (SQL Backend)
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
}
]
}New Class (KV Backend - Legacy)
{
"migrations": [
{
"tag": "v1",
"new_classes": ["Counter"]
}
]
}Rename Class
{
"migrations": [
{
"tag": "v2",
"renamed_classes": [
{ "from": "Counter", "to": "GlobalCounter" }
]
}
]
}Delete Class
{
"migrations": [
{
"tag": "v3",
"deleted_classes": ["OldCounter"]
}
]
}Transfer Class
{
"migrations": [
{
"tag": "v4",
"transferred_classes": [
{
"from": "Counter",
"from_script": "old-worker",
"to": "Counter",
"to_script": "new-worker"
}
]
}
]
}---
Common Migration Patterns
Adding New DO to Existing Project
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
},
{
"tag": "v2", // New migration
"new_sqlite_classes": ["ChatRoom"]
}
]
}Switching from KV to SQL
{
"migrations": [
{
"tag": "v1",
"new_classes": ["Counter"] // Started with KV
},
{
"tag": "v2",
"deleted_classes": ["Counter"] // Delete KV version
},
{
"tag": "v3",
"new_sqlite_classes": ["Counter"] // Create SQL version
}
]
}Note: Data is NOT automatically migrated. Implement custom migration logic if needed.
---
Migration Validation
Before Deployment
# Validate wrangler.jsonc
./scripts/validate-do-config.sh
# Check for common mistakes
jq '.migrations' wrangler.jsonc
# Verify class exports
grep -r "export.*Counter" src/Deployment
# Deploy atomically
wrangler deploy
# Check status
wrangler tailRollback
# Restore backup
cp wrangler.jsonc.backup wrangler.jsonc
# Redeploy
wrangler deploy---
Common Mistakes
❌ Missing Migration for New Class
{
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
}
// ❌ No migrations array
}❌ Wrong Migration Type (KV vs SQL)
{
"migrations": [
{
"tag": "v1",
"new_classes": ["Counter"] // ❌ Should be new_sqlite_classes
}
]
}❌ Non-Atomic Migration Tags
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
},
{
"tag": "v1", // ❌ Duplicate tag
"new_sqlite_classes": ["ChatRoom"]
}
]
}---
Migration Timeline
v1 → v2 → v3 → v4Rules:
- Tags must be unique
- Migrations are applied in order
- Cannot skip versions
- Cannot modify past migrations
---
Quick Commands
# Generate migration
./scripts/migration-generator.sh
# Validate config
./scripts/validate-do-config.sh
# Deploy
wrangler deploy
# Monitor
wrangler tail---
Last Updated: 2025-12-27
Durable Objects Migrations Guide
Complete guide to managing DO class lifecycles with migrations.
---
Why Migrations?
Migrations tell Cloudflare Workers runtime about changes to Durable Object classes:
Required for:
- ✅ Creating new DO class
- ✅ Renaming DO class
- ✅ Deleting DO class
- ✅ Transferring DO class to another Worker
NOT required for:
- ❌ Code changes to existing DO class
- ❌ Storage schema changes within DO
---
Migration Types
1. Create New DO Class
{
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter"
}
]
},
"migrations": [
{
"tag": "v1", // Unique migration identifier
"new_sqlite_classes": [ // SQLite backend (recommended)
"Counter"
]
}
]
}For KV backend (legacy):
{
"migrations": [
{
"tag": "v1",
"new_classes": ["Counter"] // KV backend (128MB limit)
}
]
}CRITICAL:
- ✅ Use
new_sqlite_classesfor new DOs (1GB storage, atomic operations) - ❌ Cannot change KV backend to SQLite after deployment
---
2. Rename DO Class
{
"durable_objects": {
"bindings": [
{
"name": "MY_DO",
"class_name": "NewClassName" // Updated class name
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["OldClassName"]
},
{
"tag": "v2", // New migration tag
"renamed_classes": [
{
"from": "OldClassName",
"to": "NewClassName"
}
]
}
]
}What happens:
- ✅ All existing DO instances keep their data
- ✅ Old bindings automatically forward to new class
- ✅
idFromName('foo')still routes to same instance - ⚠️ Must export new class in Worker code
---
3. Delete DO Class
{
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
},
{
"tag": "v2",
"deleted_classes": ["Counter"] // Mark for deletion
}
]
}What happens:
- ✅ All DO instances immediately deleted
- ✅ All storage permanently deleted
- ⚠️ CANNOT UNDO - data is gone forever
Before deleting: 1. Export data if needed 2. Update Workers that reference this DO 3. Consider rename instead (if migrating)
---
4. Transfer DO Class to Another Worker
Destination Worker:
{
"durable_objects": {
"bindings": [
{
"name": "TRANSFERRED_DO",
"class_name": "TransferredClass"
}
]
},
"migrations": [
{
"tag": "v1",
"transferred_classes": [
{
"from": "OriginalClass",
"from_script": "original-worker", // Source Worker name
"to": "TransferredClass"
}
]
}
]
}What happens:
- ✅ DO instances move to new Worker
- ✅ All storage is transferred
- ✅ Old bindings automatically forward to new Worker
- ⚠️ Must export new class in destination Worker
---
Migration Rules
Tags Must Be Unique
// ✅ CORRECT
{
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["A"] },
{ "tag": "v2", "new_sqlite_classes": ["B"] },
{ "tag": "v3", "renamed_classes": [{ "from": "A", "to": "C" }] }
]
}
// ❌ WRONG: Duplicate tag
{
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["A"] },
{ "tag": "v1", "new_sqlite_classes": ["B"] } // ERROR
]
}Tags Are Append-Only
// ✅ CORRECT: Add new tag
{
"migrations": [
{ "tag": "v1", ... },
{ "tag": "v2", ... } // Append
]
}
// ❌ WRONG: Remove or reorder
{
"migrations": [
{ "tag": "v2", ... } // Can't remove v1
]
}Migrations Are Atomic
⚠️ Cannot use gradual deployments with migrations
- All DO instances migrate at once when you deploy
- No partial rollout support
- Use canary releases at Worker level, not DO level
---
Migration Gotchas
Global Uniqueness
DO class names are globally unique per account.
// Worker A
export class Counter extends DurableObject { }
// Worker B
export class Counter extends DurableObject { }
// ❌ ERROR: Class name "Counter" already exists in accountSolution: Use unique class names (e.g., prefix with Worker name)
// Worker A
export class CounterA extends DurableObject { }
// Worker B
export class CounterB extends DurableObject { }Cannot Enable SQLite on Existing KV-backed DO
// Deployed with:
{ "tag": "v1", "new_classes": ["Counter"] } // KV backend
// ❌ WRONG: Cannot change to SQLite
{ "tag": "v2", "renamed_classes": [{ "from": "Counter", "to": "CounterSQLite" }] }
{ "tag": "v3", "new_sqlite_classes": ["CounterSQLite"] }
// ✅ CORRECT: Create new class instead
{ "tag": "v2", "new_sqlite_classes": ["CounterV2"] }
// Then migrate data from Counter to CounterV2Code Changes Don't Need Migrations
// ✅ CORRECT: Just deploy code changes
export class Counter extends DurableObject {
async increment(): Promise<number> {
// Changed implementation
let value = await this.ctx.storage.get<number>('count') || 0;
value += 2; // Changed from += 1
await this.ctx.storage.put('count', value);
return value;
}
}
// No migration needed - deploy directlyOnly schema changes (new/rename/delete/transfer) need migrations.
---
Environment-Specific Migrations
You can define migrations per environment:
{
// Top-level (default) migrations
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] }
],
"env": {
"production": {
// Production-specific migrations override top-level
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] },
{ "tag": "v2", "new_sqlite_classes": ["Analytics"] }
]
}
}
}Rules:
- If migration defined at environment level, it overrides top-level
- If NOT defined at environment level, inherits top-level
---
Migration Workflow
Example: Rename DO Class
Step 1: Current state (v1)
{
"durable_objects": {
"bindings": [{ "name": "MY_DO", "class_name": "OldName" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["OldName"] }
]
}Step 2: Update wrangler.jsonc
{
"durable_objects": {
"bindings": [{ "name": "MY_DO", "class_name": "NewName" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["OldName"] },
{ "tag": "v2", "renamed_classes": [{ "from": "OldName", "to": "NewName" }] }
]
}Step 3: Update Worker code
// Rename class
export class NewName extends DurableObject { }
export default NewName;Step 4: Deploy
npx wrangler deployMigration applies atomically on deploy.
---
Troubleshooting
Error: "Migration tag already exists"
Cause: Trying to reuse a migration tag
Solution: Use a new, unique tag
Error: "Class not found"
Cause: Class not exported from Worker
Solution: Ensure export default MyDOClass;
Error: "Cannot enable SQLite on existing class"
Cause: Trying to migrate KV-backed DO to SQLite
Solution: Create new SQLite-backed class, migrate data manually
---
Official Docs: https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
RPC vs Fetch Patterns - Decision Guide
When to use RPC methods vs HTTP fetch handler.
---
Quick Decision Matrix
| Requirement | Use | Why |
|---|---|---|
| New project (compat_date >= 2024-04-03) | RPC | Simpler, type-safe |
| Type safety important | RPC | TypeScript knows method signatures |
| Simple method calls | RPC | Less boilerplate |
| WebSocket upgrade needed | Fetch | Requires HTTP upgrade |
| Complex HTTP routing | Fetch | Full request/response control |
| Need headers, cookies, status codes | Fetch | HTTP-specific features |
| Legacy compatibility | Fetch | Pre-2024-04-03 projects |
| Auto-serialization wanted | RPC | Handles structured data automatically |
---
RPC Pattern (Recommended)
Enable RPC
Set compatibility date >= 2024-04-03:
{
"compatibility_date": "2025-10-22"
}Define RPC Methods
export class MyDO extends DurableObject {
// Public methods are automatically exposed as RPC
async increment(): Promise<number> {
// ...
}
async get(): Promise<number> {
// ...
}
// Private methods are NOT exposed
private async internalHelper(): Promise<void> {
// ...
}
}Call from Worker
const stub = env.MY_DO.getByName('my-instance');
// Direct method calls
const count = await stub.increment();
const value = await stub.get();Advantages
✅ Type-safe - TypeScript knows method signatures ✅ Less boilerplate - No HTTP ceremony ✅ Auto-serialization - Structured data works seamlessly ✅ Exception propagation - Errors thrown in DO received in Worker
Limitations
❌ Cannot use HTTP-specific features (headers, status codes) ❌ Cannot handle WebSocket upgrades ❌ Requires compat_date >= 2024-04-03
---
HTTP Fetch Pattern
Define fetch() Handler
export class MyDO extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/increment' && request.method === 'POST') {
// ...
return new Response(JSON.stringify({ count }), {
headers: { 'content-type': 'application/json' },
});
}
return new Response('Not found', { status: 404 });
}
}Call from Worker
const stub = env.MY_DO.getByName('my-instance');
const response = await stub.fetch('https://fake-host/increment', {
method: 'POST',
});
const data = await response.json();Advantages
✅ Full HTTP control - Headers, cookies, status codes ✅ WebSocket upgrades - Required for WebSocket server ✅ Complex routing - Use path, method, headers for routing ✅ Legacy compatible - Works with pre-2024-04-03
Limitations
❌ More boilerplate - Manual JSON parsing, response creation ❌ No type safety - Worker doesn't know what methods exist ❌ Manual error handling - Must parse HTTP status codes
---
Hybrid Pattern (Both)
Use both RPC and fetch() in same DO:
export class MyDO extends DurableObject {
// RPC method for simple calls
async getStatus(): Promise<{ active: boolean }> {
return { active: true };
}
// Fetch for WebSocket upgrade
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
// Handle WebSocket upgrade
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
return new Response('Not found', { status: 404 });
}
}Call from Worker:
const stub = env.MY_DO.getByName('my-instance');
// Use RPC for status
const status = await stub.getStatus();
// Use fetch for WebSocket upgrade
const response = await stub.fetch(request);---
RPC Serialization
What works:
- ✅ Primitives (string, number, boolean, null)
- ✅ Objects (plain objects)
- ✅ Arrays
- ✅ Nested structures
- ✅ Date objects
- ✅ ArrayBuffer, Uint8Array, etc.
What doesn't work:
- ❌ Functions
- ❌ Symbols
- ❌ Circular references
- ❌ Class instances (except basic types)
Example:
// ✅ WORKS
async getData(): Promise<{ users: string[]; count: number }> {
return {
users: ['alice', 'bob'],
count: 2,
};
}
// ❌ DOESN'T WORK
async getFunction(): Promise<() => void> {
return () => console.log('hello'); // Functions not serializable
}---
Error Handling
RPC Error Handling
// In DO
async doWork(): Promise<void> {
if (somethingWrong) {
throw new Error('Something went wrong');
}
}
// In Worker
try {
await stub.doWork();
} catch (error) {
console.error('RPC error:', error.message);
// Error propagated from DO
}Fetch Error Handling
// In DO
async fetch(request: Request): Promise<Response> {
if (somethingWrong) {
return new Response(JSON.stringify({ error: 'Something went wrong' }), {
status: 500,
});
}
return new Response('OK');
}
// In Worker
const response = await stub.fetch(request);
if (!response.ok) {
const error = await response.json();
console.error('Fetch error:', error);
}---
Migration from Fetch to RPC
Before (Fetch):
export class Counter extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/increment') {
let count = await this.ctx.storage.get<number>('count') || 0;
count += 1;
await this.ctx.storage.put('count', count);
return new Response(JSON.stringify({ count }), {
headers: { 'content-type': 'application/json' },
});
}
return new Response('Not found', { status: 404 });
}
}After (RPC):
export class Counter 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;
}
}
// Worker before:
const response = await stub.fetch('https://fake-host/increment');
const { count } = await response.json();
// Worker after:
const count = await stub.increment();Benefits:
- ✅ ~60% less code
- ✅ Type-safe
- ✅ Cleaner, more maintainable
---
Official Docs: https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/
Wrangler CLI Commands for Durable Objects
Complete reference for managing Durable Objects with wrangler CLI.
---
Development Commands
Dev Server
# Start local dev server
npx wrangler dev
# Dev with remote Durable Objects (not local)
npx wrangler dev --remote
# Dev with specific port
npx wrangler dev --port 8787Deployment
# Deploy to production
npx wrangler deploy
# Deploy specific environment
npx wrangler deploy --env production
# Dry run (show what would be deployed)
npx wrangler deploy --dry-run---
Durable Objects Commands
List DO Namespaces
# List all DO namespaces in account
npx wrangler d1 listView DO Objects
# List all instances of a DO class
npx wrangler durable-objects namespace list <BINDING_NAME>
# Get info about specific DO instance
npx wrangler durable-objects namespace get <BINDING_NAME> --id <OBJECT_ID>Delete DO Instances
# Delete specific DO instance (deletes all storage)
npx wrangler durable-objects namespace delete <BINDING_NAME> --id <OBJECT_ID>
# DANGEROUS: Delete all instances in namespace
npx wrangler durable-objects namespace delete-all <BINDING_NAME>---
Logs and Debugging
Tail Logs
# Tail logs from deployed Worker
npx wrangler tail
# Tail with filter
npx wrangler tail --format pretty
# Tail specific DO
npx wrangler tail --search "DurableObject"View Logs
# View recent logs
npx wrangler pages deployment tail
# Filter by log level
npx wrangler tail --level error---
Type Generation
Generate TypeScript Types
# Generate types for bindings
npx wrangler types
# This creates worker-configuration.d.ts with:
# - DurableObjectNamespace types
# - Env interface
# - Binding types---
Migrations
Migrations are configured in `wrangler.jsonc`, not via CLI commands.
Example migration workflow:
1. Edit wrangler.jsonc to add migration 2. Run npx wrangler deploy 3. Migration applies atomically on deploy
See migrations-guide.md for detailed migration patterns.
---
Useful Flags
Common Flags
# Show help
npx wrangler --help
npx wrangler deploy --help
# Specify config file
npx wrangler deploy --config wrangler.production.jsonc
# Specify environment
npx wrangler deploy --env staging
# Verbose output
npx wrangler deploy --verbose
# Compatibility date
npx wrangler deploy --compatibility-date 2025-10-22---
Example Workflows
Initial Setup
# 1. Initialize project
npm create cloudflare@latest my-do-app -- \
--template=cloudflare/durable-objects-template \
--ts --git --deploy false
cd my-do-app
# 2. Install dependencies
npm install
# 3. Start dev server
npm run dev
# 4. Deploy
npm run deployUpdate and Deploy
# 1. Make code changes
# 2. Test locally
npm run dev
# 3. Deploy
npm run deploy
# 4. Tail logs
npx wrangler tailAdd New DO Class
# 1. Create DO class file (e.g., src/counter.ts)
# 2. Update wrangler.jsonc:
# - Add binding
# - Add migration
# 3. Deploy
npm run deploy---
Troubleshooting
Check Deployment Status
npx wrangler deployments listRollback Deployment
# Cloudflare automatically keeps recent versions
# Use dashboard to rollback if neededClear Local Cache
rm -rf .wrangler---
Official Docs: https://developers.cloudflare.com/workers/wrangler/commands/