cloudflare-durable-objects

jezweb's avatarfrom jezweb

Build stateful Durable Objects for real-time apps, WebSocket servers, coordination, and persistent state. Use when: implementing chat rooms, multiplayer games, rate limiting, session management, WebSocket hibernation, or troubleshooting class export, migration, WebSocket state loss, or binding errors.

109stars🔀16forks📁View on GitHub🕐Updated Dec 28, 2025

When & Why to Use This Skill

This Claude skill provides expert guidance on building and managing stateful Cloudflare Durable Objects for real-time applications, WebSocket servers, and persistent state coordination. It streamlines the development lifecycle by offering production-ready patterns for SQLite storage, WebSocket hibernation, and the Alarms API, while specifically preventing 20 documented architectural errors such as state loss, binding issues, and migration failures.

Use Cases

  • 1. Real-time Collaboration & Gaming: Implementing low-latency chat rooms, multiplayer game state coordination, and live collaborative editing tools using WebSocket hibernation to optimize costs.
  • 2. Stateful Session Management: Building robust user session trackers, shopping carts, and complex state machines that require high consistency across distributed serverless environments.
  • 3. Distributed Rate Limiting: Creating high-performance global or per-user rate limiters and request throttlers leveraging atomic storage operations.
  • 4. Reliable Background Tasks: Utilizing the Alarms API for scheduled data batching, periodic cleanup, and automated retry logic that survives serverless instance evictions.
  • 5. Serverless SQL Integration: Leveraging the built-in 10GB SQLite backend for complex relational queries, atomic transactions, and persistent data storage within a serverless architecture.
namecloudflare-durable-objects
description|
Build stateful Durable Objects for real-time apps, WebSocket servers, coordination, and persistent state. Prevents 20 documented errors. Use whenimplementing chat rooms, multiplayer games, rate limiting, session management, WebSocket hibernation, or troubleshooting class export, migration, WebSocket state loss, boolean binding, RPC streams, or binding errors.
user-invocabletrue

Cloudflare Durable Objects

Status: Production Ready ✅ Last Updated: 2026-01-21 Dependencies: cloudflare-worker-base (recommended) Latest Versions: wrangler@4.58.0, @cloudflare/workers-types@4.20260109.0 Official Docs: https://developers.cloudflare.com/durable-objects/

Recent Updates (2025):

  • Oct 2025: WebSocket message size 1 MiB → 32 MiB, Data Studio UI for SQLite DOs (view/edit storage in dashboard)
  • Aug 2025: getByName() API shortcut for named DOs
  • June 2025: @cloudflare/actors library (beta) - recommended SDK with migrations, alarms, Actor class pattern. Note: Beta stability - see active issues before production use (RPC serialization, vitest integration, memory management)
  • May 2025: Python Workers support for Durable Objects
  • April 2025: SQLite GA with 10GB storage (beta → GA, 1GB → 10GB), Free tier access
  • Feb 2025: PRAGMA optimize support, improved error diagnostics with reference IDs

Quick Start

Scaffold new DO project:

npm create cloudflare@latest my-durable-app -- --template=cloudflare/durable-objects-template --ts

Or add to existing Worker:

// src/counter.ts - Durable Object class
import { DurableObject } from 'cloudflare:workers';

export class Counter extends DurableObject {
  async increment(): Promise<number> {
    let value = (await this.ctx.storage.get<number>('value')) || 0;
    await this.ctx.storage.put('value', ++value);
    return value;
  }
}
export default Counter;  // CRITICAL: Export required
// wrangler.jsonc - Configuration
{
  "durable_objects": {
    "bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Counter"] }  // SQLite backend (10GB limit)
  ]
}
// src/index.ts - Worker
import { Counter } from './counter';
export { Counter };

export default {
  async fetch(request: Request, env: { COUNTER: DurableObjectNamespace<Counter> }) {
    const stub = env.COUNTER.getByName('global-counter');  // Aug 2025: getByName() shortcut
    return new Response(`Count: ${await stub.increment()}`);
  }
};

DO Class Essentials

import { DurableObject } from 'cloudflare:workers';

export class MyDO extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);  // REQUIRED first line

    // Load state before requests (optional)
    ctx.blockConcurrencyWhile(async () => {
      this.value = await ctx.storage.get('key') || defaultValue;
    });
  }

  // RPC methods (recommended)
  async myMethod(): Promise<string> { return 'Hello'; }

  // HTTP fetch handler (optional)
  async fetch(request: Request): Promise<Response> { return new Response('OK'); }
}

export default MyDO;  // CRITICAL: Export required

// Worker must export DO class too
import { MyDO } from './my-do';
export { MyDO };

Constructor Rules:

  • ✅ Call super(ctx, env) first
  • ✅ Keep minimal - heavy work blocks hibernation wake
  • ✅ Use ctx.blockConcurrencyWhile() for storage initialization
  • ❌ Never setTimeout/setInterval (use alarms)
  • ❌ Don't rely on in-memory state with WebSockets (persist to storage)

Storage API

Two backends available:

  • SQLite (recommended): 10GB storage, SQL queries, atomic operations, PITR
  • KV: 128MB storage, key-value only

Enable SQLite in migrations:

{ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }] }

SQL API (SQLite backend)

export class MyDO extends DurableObject {
  sql: SqlStorage;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.sql = ctx.storage.sql;

    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, text TEXT, created_at INTEGER);
      CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at);
      PRAGMA optimize;  // Feb 2025: Query performance optimization
    `);
  }

  async addMessage(text: string): Promise<number> {
    const cursor = this.sql.exec('INSERT INTO messages (text, created_at) VALUES (?, ?) RETURNING id', text, Date.now());
    return cursor.one<{ id: number }>().id;
  }

  async getMessages(limit = 50): Promise<any[]> {
    return this.sql.exec('SELECT * FROM messages ORDER BY created_at DESC LIMIT ?', limit).toArray();
  }
}

SQL Methods:

  • sql.exec(query, ...params) → cursor
  • cursor.one<T>() → single row (throws if none)
  • cursor.one<T>({ allowNone: true }) → row or null
  • cursor.toArray<T>() → all rows
  • ctx.storage.transactionSync(() => { ... }) → atomic multi-statement

Best Practices:

  • ✅ Use ? placeholders for parameterized queries
  • ✅ Create indexes on frequently queried columns
  • ✅ Use PRAGMA optimize after schema changes
  • ✅ Add STRICT keyword to table definitions to enforce type affinity and catch type mismatches early
  • ✅ Convert booleans to integers (0/1) - booleans bind as strings "true"/"false" in SQLite backend

Key-Value API (both backends)

// Single operations
await this.ctx.storage.put('key', value);
const value = await this.ctx.storage.get<T>('key');
await this.ctx.storage.delete('key');

// Batch operations
await this.ctx.storage.put({ key1: val1, key2: val2 });
const map = await this.ctx.storage.get(['key1', 'key2']);
await this.ctx.storage.delete(['key1', 'key2']);

// List and delete all
const map = await this.ctx.storage.list({ prefix: 'user:', limit: 100 });
await this.ctx.storage.deleteAll();  // Atomic on SQLite only

// Transactions
await this.ctx.storage.transaction(async (txn) => {
  await txn.put('key1', val1);
  await txn.put('key2', val2);
});

Storage Limits: SQLite 10GB (April 2025 GA) | KV 128MB


WebSocket Hibernation API

Capabilities:

  • Thousands of WebSocket connections per instance
  • Hibernate when idle (~10s no activity) to save costs
  • Auto wake-up when messages arrive
  • Message size limit: 32 MiB (Oct 2025, up from 1 MiB)

How it works:

  1. Active → handles messages
  2. Idle → ~10s no activity
  3. Hibernation → in-memory state cleared, WebSockets stay connected
  4. Wake → message arrives → constructor runs → handler called

CRITICAL: In-memory state is lost on hibernation. Use serializeAttachment() to persist per-WebSocket metadata.

Hibernation-Safe Pattern

export class ChatRoom extends DurableObject {
  sessions: Map<WebSocket, { userId: string; username: string }>;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.sessions = new Map();

    // CRITICAL: Restore WebSocket metadata 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);

    const url = new URL(request.url);
    const metadata = { userId: url.searchParams.get('userId'), username: url.searchParams.get('username') };

    // CRITICAL: Use ctx.acceptWebSocket(), NOT ws.accept()
    this.ctx.acceptWebSocket(server);
    server.serializeAttachment(metadata);  // Persist across hibernation
    this.sessions.set(server, metadata);

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    const session = this.sessions.get(ws);
    // Handle message (max 32 MiB since Oct 2025)
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
    this.sessions.delete(ws);
    ws.close(code, 'Closing');
  }

  async webSocketError(ws: WebSocket, error: any): Promise<void> {
    this.sessions.delete(ws);
  }
}

Hibernation Rules:

  • ctx.acceptWebSocket(ws) - enables hibernation
  • ws.serializeAttachment(data) - persist metadata
  • ctx.getWebSockets().forEach() - restore in constructor
  • ✅ Use alarms instead of setTimeout/setInterval
  • ws.accept() - standard API, no hibernation
  • setTimeout/setInterval - prevents hibernation
  • ❌ In-progress fetch() - blocks hibernation

Alarms API

Schedule DO to wake at future time. Use for: batching, cleanup, reminders, periodic tasks.

export class Batcher extends DurableObject {
  async addItem(item: string): Promise<void> {
    // Add to buffer
    const buffer = await this.ctx.storage.get<string[]>('buffer') || [];
    buffer.push(item);
    await this.ctx.storage.put('buffer', buffer);

    // Schedule alarm if not set
    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> {
    if (info.retryCount > 3) return;  // Give up after 3 retries

    const buffer = await this.ctx.storage.get<string[]>('buffer') || [];
    await this.processBatch(buffer);
    await this.ctx.storage.put('buffer', []);
    // Alarm auto-deleted after success
  }
}

API Methods:

  • await ctx.storage.setAlarm(Date.now() + 60000) - set alarm (overwrites existing)
  • await ctx.storage.getAlarm() - get timestamp or null
  • await ctx.storage.deleteAlarm() - cancel alarm
  • async alarm(info) - handler called when alarm fires

Behavior:

  • ✅ At-least-once execution, auto-retries (up to 6x, exponential backoff)
  • ✅ Survives hibernation/eviction
  • ✅ Auto-deleted after success
  • ⚠️ One alarm per DO (new alarm overwrites)

RPC vs HTTP Fetch

RPC (Recommended): Direct method calls, type-safe, simple

// DO class
export class Counter extends DurableObject {
  async increment(): Promise<number> {
    let value = (await this.ctx.storage.get<number>('count')) || 0;
    await this.ctx.storage.put('count', ++value);
    return value;
  }
}

// Worker calls
const stub = env.COUNTER.getByName('my-counter');
const count = await stub.increment();  // Type-safe!

HTTP Fetch: Request/response pattern, required for WebSocket upgrades

// DO class
export class Counter extends DurableObject {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === '/increment') {
      let value = (await this.ctx.storage.get<number>('count')) || 0;
      await this.ctx.storage.put('count', ++value);
      return new Response(JSON.stringify({ count: value }));
    }
    return new Response('Not found', { status: 404 });
  }
}

// Worker calls
const stub = env.COUNTER.getByName('my-counter');
const response = await stub.fetch('https://fake-host/increment', { method: 'POST' });
const data = await response.json();

When to use: RPC for new projects (simpler), HTTP Fetch for WebSocket upgrades or complex routing


Getting DO Stubs

Three ways to get IDs:

  1. idFromName(name) - Consistent routing (same name = same DO)
const stub = env.CHAT_ROOM.getByName('room-123');  // Aug 2025: Shortcut for idFromName + get
// Use for: chat rooms, user sessions, per-tenant logic, singletons
  1. newUniqueId() - Random unique ID (must store for reuse)
const id = env.MY_DO.newUniqueId({ jurisdiction: 'eu' });  // Optional: EU compliance
const idString = id.toString();  // Save to KV/D1 for later
  1. idFromString(idString) - Recreate from saved ID
const id = env.MY_DO.idFromString(await env.KV.get('session:123'));
const stub = env.MY_DO.get(id);

Location hints (best-effort):

const stub = env.MY_DO.get(id, { locationHint: 'enam' });  // wnam, enam, sam, weur, eeur, apac, oc, afr, me

Jurisdiction (strict enforcement):

const id = env.MY_DO.newUniqueId({ jurisdiction: 'eu' });  // Options: 'eu', 'fedramp'
// Cannot combine with location hints, higher latency outside jurisdiction

Migrations

Required for: create, rename, delete, transfer DO classes

1. Create:

{ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }] }  // SQLite 10GB
// Or: "new_classes": ["Counter"]  // KV 128MB (legacy)

2. Rename:

{ "migrations": [
  { "tag": "v1", "new_sqlite_classes": ["OldName"] },
  { "tag": "v2", "renamed_classes": [{ "from": "OldName", "to": "NewName" }] }
]}

3. Delete:

{ "migrations": [
  { "tag": "v1", "new_sqlite_classes": ["Counter"] },
  { "tag": "v2", "deleted_classes": ["Counter"] }  // Immediate deletion, cannot undo
]}

4. Transfer:

{ "migrations": [{ "tag": "v1", "transferred_classes": [
  { "from": "OldClass", "from_script": "old-worker", "to": "NewClass" }
]}]}

Migration Rules:

  • ❌ Atomic (all instances migrate at once, no gradual rollout)
  • ❌ Tags are unique and append-only
  • ❌ Cannot enable SQLite on existing KV-backed DOs
  • ✅ Code changes don't need migrations (only schema changes)
  • ✅ Class names globally unique per account

Common Patterns

Rate Limiting:

async checkLimit(userId: string, limit: number, window: number): Promise<boolean> {
  const requests = (await this.ctx.storage.get<number[]>(`rate:${userId}`)) || [];
  const valid = requests.filter(t => Date.now() - t < window);
  if (valid.length >= limit) return false;
  valid.push(Date.now());
  await this.ctx.storage.put(`rate:${userId}`, valid);
  return true;
}

Session Management with TTL:

async set(key: string, value: any, ttl?: number): Promise<void> {
  const expiresAt = ttl ? Date.now() + ttl : null;
  this.sql.exec('INSERT OR REPLACE INTO session (key, value, expires_at) VALUES (?, ?, ?)',
    key, JSON.stringify(value), expiresAt);
}

async alarm(): Promise<void> {
  this.sql.exec('DELETE FROM session WHERE expires_at < ?', Date.now());
  await this.ctx.storage.setAlarm(Date.now() + 3600000);  // Hourly cleanup
}

Leader Election:

async electLeader(workerId: string): Promise<boolean> {
  try {
    this.sql.exec('INSERT INTO leader (id, worker_id, elected_at) VALUES (1, ?, ?)', workerId, Date.now());
    return true;
  } catch { return false; }  // Already has leader
}

Multi-DO Coordination:

// Coordinator delegates to child DOs
const gameRoom = env.GAME_ROOM.getByName(gameId);
await gameRoom.initialize();
await this.ctx.storage.put(`game:${gameId}`, { created: Date.now() });

Critical Rules

Always Do

Export DO class from Worker

export class MyDO extends DurableObject { }
export default MyDO;  // Required

Call super(ctx, env) in constructor

constructor(ctx: DurableObjectState, env: Env) {
  super(ctx, env);  // Required first line
}

Use new_sqlite_classes for new DOs

{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }

Use ctx.acceptWebSocket() for hibernation

this.ctx.acceptWebSocket(server);  // Enables hibernation

Persist critical state to storage (not just memory)

await this.ctx.storage.put('important', value);

Use alarms instead of setTimeout/setInterval

await this.ctx.storage.setAlarm(Date.now() + 60000);

Use parameterized SQL queries

this.sql.exec('SELECT * FROM table WHERE id = ?', id);

Minimize constructor work

constructor(ctx, env) {
  super(ctx, env);
  // Minimal initialization only
  ctx.blockConcurrencyWhile(async () => {
    // Load from storage
  });
}

Never Do

Create DO without migration

// Missing migrations array = error

Forget to export DO class

class MyDO extends DurableObject { }
// Missing: export default MyDO;

Use setTimeout or setInterval

setTimeout(() => {}, 1000);  // Prevents hibernation

Rely only on in-memory state with WebSockets

// ❌ WRONG: this.sessions will be lost on hibernation
// ✅ CORRECT: Use serializeAttachment()

Deploy migrations gradually

# Migrations are atomic - cannot use gradual rollout

Enable SQLite on existing KV-backed DO

// Not supported - must create new DO class instead

Use standard WebSocket API expecting hibernation

ws.accept();  // ❌ No hibernation
this.ctx.acceptWebSocket(ws);  // ✅ Hibernation enabled

Assume location hints are guaranteed

// Location hints are best-effort only

Known Issues Prevention

This skill prevents 20 documented issues:

Issue #1: Class Not Exported

Error: "binding not found" or "Class X not found" Source: https://developers.cloudflare.com/durable-objects/get-started/ Why It Happens: DO class not exported from Worker Prevention:

export class MyDO extends DurableObject { }
export default MyDO;  // ← Required

Issue #2: Missing Migration

Error: "migrations required" or "no migration found for class" Source: https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/ Why It Happens: Created DO class without migration entry Prevention: Always add migration when creating new DO class

{
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["MyDO"] }
  ]
}

Issue #3: Wrong Migration Type (KV vs SQLite)

Error: Schema errors, storage API mismatch Source: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/ Why It Happens: Used new_classes instead of new_sqlite_classes Prevention: Use new_sqlite_classes for SQLite backend (recommended)

Issue #4: Constructor Overhead Blocks Hibernation Wake

Error: Slow hibernation wake-up times Source: https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/ Why It Happens: Heavy work in constructor Prevention: Minimize constructor, use blockConcurrencyWhile()

constructor(ctx, env) {
  super(ctx, env);
  ctx.blockConcurrencyWhile(async () => {
    // Load from storage
  });
}

Issue #5: setTimeout Breaks Hibernation

Error: DO never hibernates, high duration charges Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ Why It Happens: setTimeout/setInterval prevents hibernation Prevention: Use alarms API instead

// ❌ WRONG
setTimeout(() => {}, 1000);

// ✅ CORRECT
await this.ctx.storage.setAlarm(Date.now() + 1000);

Issue #6: In-Memory State Lost on Hibernation

Error: WebSocket metadata lost, state reset unexpectedly Source: https://developers.cloudflare.com/durable-objects/best-practices/websockets/ Why It Happens: Relied on in-memory state that's cleared on hibernation Prevention: Use serializeAttachment() for WebSocket metadata

ws.serializeAttachment({ userId, username });

// Restore in constructor
ctx.getWebSockets().forEach(ws => {
  const metadata = ws.deserializeAttachment();
  this.sessions.set(ws, metadata);
});

Issue #7: Outgoing WebSocket Cannot Hibernate

Error: High charges despite hibernation API Source: Cloudflare Docs | GitHub Issue #4864 Why It Happens: Durable Objects maintaining persistent connections to external WebSocket services using new WebSocket('url') cannot hibernate and remain pinned in memory indefinitely Use Cases Affected:

  • Real-time database subscriptions (Supabase, Firebase)
  • Message brokers (Redis Streams, Apache Kafka)
  • WebSocket connections to external real-time services
  • Inter-service communication Prevention: Only use hibernation for server-side (incoming) WebSockets via ctx.acceptWebSocket(). Outgoing WebSocket connections created with new WebSocket(url) prevent hibernation. Redesign architecture to avoid outgoing WebSocket connections from Durable Objects if hibernation is required.

Issue #8: Global Uniqueness Confusion

Error: Unexpected DO class name conflicts Source: https://developers.cloudflare.com/durable-objects/platform/known-issues/#global-uniqueness Why It Happens: DO class names are globally unique per account Prevention: Understand DO class names are shared across all Workers in account

Issue #9: deleteAll Issues

Error: Storage not fully deleted, billing continues; or internal error in alarm handler Source: KV Storage API | GitHub Issue #2993 Why It Happens:

  • KV backend deleteAll() can fail partially (not atomic)
  • SQLite: calling deleteAll() in alarm handler causes internal error and retry loop (fixed in runtime) Prevention:
  • Use SQLite backend for atomic deleteAll
  • In alarm handlers, call deleteAlarm() BEFORE deleteAll():
async alarm(info: { retryCount: number }): Promise<void> {
  await this.ctx.storage.deleteAlarm();  // ← Call first
  await this.ctx.storage.deleteAll();    // Then delete all
}

Issue #10: Binding Name Mismatch

Error: Runtime error accessing DO binding Source: https://developers.cloudflare.com/durable-objects/get-started/ Why It Happens: Binding name in wrangler.jsonc doesn't match code Prevention: Ensure consistency

{ "bindings": [{ "name": "MY_DO", "class_name": "MyDO" }] }
env.MY_DO.getByName('instance');  // Must match binding name

Issue #11: State Size Exceeded

Error: "state limit exceeded" or storage errors Source: https://developers.cloudflare.com/durable-objects/platform/pricing/ Why It Happens: Exceeded 1GB (SQLite) or 128MB (KV) limit Prevention: Monitor storage size, implement cleanup with alarms

Issue #12: Migration Not Atomic

Error: Gradual deployment blocked Source: https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/ Why It Happens: Tried to use gradual rollout with migrations Prevention: Migrations deploy atomically across all instances

Issue #13: Location Hint Ignored

Error: DO created in wrong region Source: https://developers.cloudflare.com/durable-objects/reference/data-location/ Why It Happens: Location hints are best-effort, not guaranteed Prevention: Use jurisdiction for strict requirements

Issue #14: Alarm Retry Failures

Error: Tasks lost after alarm failures Source: https://developers.cloudflare.com/durable-objects/api/alarms/ Why It Happens: Alarm handler throws errors repeatedly Prevention: Implement idempotent alarm handlers

async alarm(info: { retryCount: number }): Promise<void> {
  if (info.retryCount > 3) {
    console.error('Giving up after 3 retries');
    return;
  }
  // Idempotent operation
}

Issue #15: Fetch Blocks Hibernation

Error: DO never hibernates despite using hibernation API Source: https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/ Why It Happens: In-progress fetch() requests prevent hibernation Prevention: Ensure all async I/O completes before idle period

Issue #16: Boolean Values Bind as Strings in SQLite

Error: Boolean columns contain strings "true"/"false" instead of integers 0/1; SQL queries with boolean comparisons fail Source: GitHub Issue #9964 Why It Happens: JavaScript boolean values are serialized as strings in Durable Objects SQLite (inconsistent with D1 behavior) Prevention: Manually convert booleans to integers and use STRICT tables

// Convert booleans to integers
this.sql.exec('INSERT INTO test (bool_col) VALUES (?)', value ? 1 : 0);

// Use STRICT tables to catch type mismatches early
this.sql.exec(`
  CREATE TABLE IF NOT EXISTS test (
    id INTEGER PRIMARY KEY,
    bool_col INTEGER NOT NULL
  ) STRICT;
`);

Issue #17: RPC ReadableStream Cancel Logs False Network Errors

Error: Wrangler dev logs show "Network connection lost" when canceling ReadableStream from RPC, despite correct cancellation Source: GitHub Issue #11071 Why It Happens: Canceling ReadableStream returned from Durable Object via RPC triggers misleading error logs in Wrangler dev (presentation issue, not runtime bug) Prevention: No workaround available. The cancellation works correctly - ignore the false error logs in Wrangler dev. Issue does not appear in production or workerd-only setup.

Issue #18: blockConcurrencyWhile Does Not Block in Local Dev (Fixed)

Error: Constructor's blockConcurrencyWhile doesn't block requests in local dev, causing race conditions hidden during development Source: GitHub Issue #8686 Why It Happens: Bug in older @cloudflare/vite-plugin and wrangler versions Prevention: Upgrade to @cloudflare/vite-plugin v1.3.1+ and wrangler v4.18.0+ where this is fixed

Issue #19: RPC Between Multiple wrangler dev Sessions Not Supported

Error: "Cannot access MyDurableObject#myMethod as Durable Object RPC is not yet supported between multiple wrangler dev sessions" Source: GitHub Issue #11944 Why It Happens: Accessing a Durable Object over RPC from multiple wrangler dev instances (e.g., separate Workers in monorepo) is not yet supported in local dev Prevention: Use wrangler dev -c config1 -c config2 to run multiple workers in single session, or use HTTP fetch instead of RPC for cross-worker DO communication during local development

Issue #20: state.id.name Undefined in Constructor (vitest Regression)

Error: DurableObjectState.id.name is undefined in constructor when using @cloudflare/vitest-pool-workers 0.8.71 Source: GitHub Issue #11580 Why It Happens: Regression in vitest-pool-workers 0.8.71 (worked in 0.8.38) Prevention: Downgrade to @cloudflare/vitest-pool-workers@0.8.38 or upgrade to later version where this is fixed


Configuration & Types

wrangler.jsonc:

{
  "compatibility_date": "2025-11-23",
  "durable_objects": {
    "bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Counter"] },
    { "tag": "v2", "renamed_classes": [{ "from": "Counter", "to": "CounterV2" }] }
  ]
}

TypeScript:

import { DurableObject, DurableObjectState, DurableObjectNamespace } from 'cloudflare:workers';

interface Env { MY_DO: DurableObjectNamespace<MyDurableObject>; }

export class MyDurableObject extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.sql = ctx.storage.sql;
  }
}

Official Documentation


Questions? Issues?

  1. Check references/top-errors.md for common problems
  2. Review templates/ for working examples
  3. Consult official docs: https://developers.cloudflare.com/durable-objects/
  4. Verify migrations configuration carefully

Last verified: 2026-01-21 | Skill version: 3.1.0 | Changes: Added 5 new issues (boolean binding, RPC stream cancel, blockConcurrencyWhile local dev, RPC multi-session, vitest regression), expanded Issue #7 (outgoing WebSocket use cases) and Issue #9 (deleteAll alarm interaction), added STRICT tables best practice, updated @cloudflare/actors beta warning

cloudflare-durable-objects – AI Agent Skills | Claude Skills