Skip to content

Fix: Cloudflare Durable Objects Not Working — ID Strategy, Storage API, WebSocket Hibernation, Alarms

FixDevs ·

Quick Answer

How to fix Cloudflare Durable Objects errors — idFromName vs newUniqueId, Storage transactions, blockConcurrencyWhile, WebSocket Hibernation API, alarms, migrations, and class binding setup.

The Error

You bind a Durable Object class and Wrangler refuses to deploy:

You must add a migration to your wrangler.toml for new Durable Object 
class "Counter".

Or each request gets a different DO instance even though you used a key:

const id = env.COUNTER.newUniqueId();
const stub = env.COUNTER.get(id);
// Each request: new ID → new DO instance → state isn't shared.

Or storage.get returns undefined despite a recent put:

await this.storage.put("count", 5);
const value = await this.storage.get("count");
console.log(value);  // undefined sometimes

Or WebSocket connections close after 30 seconds idle:

state.acceptWebSocket(ws);
// Client disconnects after a minute with no clear reason.

Why This Happens

Durable Objects (DOs) are stateful “actor” objects pinned to a single location. Each DO instance has its own JavaScript runtime, persistent storage, and identity. Three core concepts to get right:

  • Identity. A DO has an ID. newUniqueId() makes a random ID; idFromName("room-123") makes a deterministic ID from a name. The same name always maps to the same DO; a unique ID maps to a unique DO. Mixing these up means losing state.
  • Storage is per-DO. Each DO instance has its own KV-like storage (this.state.storage). Storage is strongly consistent — but writes are async and may not be visible to subsequent reads in the same input gate if you don’t await them.
  • Migrations declare class changes. When you add or rename a DO class, you must declare a migration in wrangler.toml. The first deploy creates the namespace; renames or deletes require explicit migration steps.
  • WebSocket Hibernation. Long-lived WebSockets used to keep DOs in memory indefinitely. The Hibernation API lets DOs hibernate while WebSockets stay connected — saves money but requires different code patterns.

Fix 1: Set Up the Binding and Migration

wrangler.toml:

name = "my-worker"
main = "src/worker.ts"
compatibility_date = "2026-05-01"

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

[[migrations]]
tag = "v1"
new_classes = ["Counter"]

The migration is required:

  • First-time class: new_classes = ["..."] creates the namespace.
  • Rename: renamed_classes = [{ from = "Old", to = "New" }].
  • Delete: deleted_classes = ["..."] (irreversible — destroys all DO instances and their storage).
  • SQLite-backed DO (newer feature): new_sqlite_classes = ["..."] instead of new_classes.

Counter class:

export class Counter {
  state: DurableObjectState;
  
  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const value = (await this.state.storage.get<number>("count")) ?? 0;
    await this.state.storage.put("count", value + 1);
    return Response.json({ count: value + 1 });
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const id = env.COUNTER.idFromName("global");
    const stub = env.COUNTER.get(id);
    return await stub.fetch(request);
  },
};

Pro Tip: Always tag migrations (tag = "v1", tag = "v2"). Wrangler tracks applied migrations by tag — without tags, the system may re-apply or skip migrations inconsistently.

Fix 2: Pick the Right ID Strategy

idFromName(name) — deterministic. Same name → same DO instance:

const id = env.COUNTER.idFromName("global-counter");
// Always the same DO, regardless of who calls.

Use for:

  • Per-user state (idFromName(user-${userId})).
  • Per-room state in chat apps (idFromName(room-${roomId})).
  • Global singletons (idFromName("global")).

newUniqueId() — random. Each call returns a new DO:

const id = env.COUNTER.newUniqueId();
// New DO every call. Save the ID somewhere if you want to find it again.

Use for:

  • One-off ephemeral state.
  • Migration / cleanup scenarios where you don’t want existing IDs.
  • Rarely — most DOs are named.

idFromString(string) — restore an ID from a stored hex string:

const id = env.COUNTER.idFromString(savedIdHexString);

Use when you saved an ID (from newUniqueId().toString()) and need to look up that DO later.

Common Mistake: Calling newUniqueId() per request and expecting state to persist. Each call makes a fresh DO with empty storage. Use idFromName with a stable key.

Fix 3: Storage API Patterns

// Single key:
await this.state.storage.put("count", 5);
const value = await this.state.storage.get<number>("count");

// Multiple keys (atomic):
await this.state.storage.put({
  count: 5,
  user: { id: 1, name: "Alice" },
  lastActive: Date.now(),
});

const all = await this.state.storage.get<Record<string, any>>(["count", "user"]);
console.log(all.get("count"));

// List with prefix:
const sessions = await this.state.storage.list<Session>({
  prefix: "session:",
  limit: 100,
});

// Delete:
await this.state.storage.delete("count");
await this.state.storage.delete(["count", "user"]);
await this.state.storage.deleteAll();  // Drops everything (irreversible)

For atomic multi-key updates with reads (a “transaction”):

await this.state.storage.transaction(async (txn) => {
  const balance = (await txn.get<number>("balance")) ?? 0;
  if (balance >= 100) {
    await txn.put("balance", balance - 100);
    await txn.put("lastWithdrawal", Date.now());
    return { success: true };
  } else {
    return { success: false };
  }
});

The transaction sees a consistent snapshot. Throws roll back the transaction.

For preventing concurrent requests while initializing:

constructor(state: DurableObjectState, env: Env) {
  this.state = state;
  state.blockConcurrencyWhile(async () => {
    // Block all incoming requests until this completes.
    this.cache = (await state.storage.get("cache")) ?? new Map();
  });
}

blockConcurrencyWhile is essential for initialization that depends on storage. Without it, two parallel requests could race to load the cache.

Pro Tip: DOs are single-threaded per instance. Use plain JS Map/Set for in-memory state and write to storage periodically — no race conditions to worry about.

Fix 4: WebSocket Hibernation

Old API (deprecated for cost reasons):

async fetch(request: Request) {
  const upgradeHeader = request.headers.get("Upgrade");
  if (upgradeHeader === "websocket") {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    server.accept();
    server.addEventListener("message", (event) => { ... });
    return new Response(null, { status: 101, webSocket: client });
  }
}

This keeps the DO awake for the entire WebSocket lifetime — billable.

New Hibernation API (lower cost):

export class ChatRoom {
  state: DurableObjectState;
  
  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request) {
    const upgradeHeader = request.headers.get("Upgrade");
    if (upgradeHeader === "websocket") {
      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);
      
      // Attach the server-side WebSocket for hibernation:
      this.state.acceptWebSocket(server, ["chat"]);
      
      return new Response(null, { status: 101, webSocket: client });
    }
  }

  // Called when a message arrives (DO may wake from hibernation):
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    const data = typeof message === "string" ? message : new TextDecoder().decode(message);
    
    // Broadcast to other clients in the same room:
    for (const otherWs of this.state.getWebSockets("chat")) {
      if (otherWs !== ws) otherWs.send(data);
    }
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string) {
    // Clean up on disconnect
  }
}

With hibernation:

  • DOs sleep between messages.
  • webSocketMessage, webSocketClose, webSocketError are class methods, called when events arrive.
  • WebSockets stay connected even while the DO is hibernated.
  • Costs scale by request count, not by connection time.

For thousands of long-lived WebSocket connections, hibernation cuts costs ~10-100x.

Common Mistake: Storing WebSockets in this.someMap. The DO hibernates and the in-memory map is lost. Use state.getWebSockets(tag) to retrieve them after wake — they’re stored persistently.

Fix 5: Alarms

DO alarms trigger a callback at a future time:

async fetch(request: Request) {
  // Schedule cleanup in 1 hour:
  await this.state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
  return Response.json({ scheduled: true });
}

async alarm() {
  // Called when the scheduled time arrives.
  await this.state.storage.deleteAll();
  // Optionally reschedule:
  await this.state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
}

Each DO can have one pending alarm. Setting a new alarm overwrites the previous one.

Use cases:

  • Cleanup after inactivity.
  • Periodic heartbeats.
  • Scheduled batch operations.
  • Rate limit window resets.

To clear:

await this.state.storage.deleteAlarm();

To check if an alarm is set:

const next = await this.state.storage.getAlarm();
console.log(next);  // number | null

Pro Tip: Alarms fire at-least-once. The DO is woken up even if hibernating. For idempotent operations, this is fine; for non-idempotent, track if you’ve already run.

Fix 6: RPC Methods (Workers RPC)

For more ergonomic DO calls, use class methods (newer Workers RPC syntax):

export class Counter extends DurableObject {
  async increment(amount: number = 1): Promise<number> {
    const current = (await this.ctx.storage.get<number>("count")) ?? 0;
    const next = current + amount;
    await this.ctx.storage.put("count", next);
    return next;
  }

  async getValue(): Promise<number> {
    return (await this.ctx.storage.get<number>("count")) ?? 0;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const id = env.COUNTER.idFromName("global");
    const stub = env.COUNTER.get(id);
    
    const newValue = await stub.increment(5);  // Type-safe RPC
    return Response.json({ value: newValue });
  },
};

No more fetch(new Request("/increment")) style URL routing — call methods directly with TypeScript types.

The DurableObject base class provides this.ctx (replacing this.state in some patterns). It’s the modern API.

Common Mistake: Returning non-serializable values from RPC methods. Functions, DOM objects, class instances all fail. Stick to JSON-compatible values.

Fix 7: Class Migrations

Renaming a DO class:

[[migrations]]
tag = "v2"
renamed_classes = [
  { from = "Counter", to = "EventCounter" }
]

Existing data is preserved; the class name changes.

Deleting a DO class (destroys all data):

[[migrations]]
tag = "v3"
deleted_classes = ["OldClass"]

Splitting an existing class into two — there’s no built-in support. You’d have to:

  1. Add a new DO class.
  2. Migrate data programmatically (read from old, write to new).
  3. Delete the old class.

Migrations are tagged and applied in order. Wrangler tracks applied migrations.

Pro Tip: Test migrations against a staging deploy first. A deleted_classes migration in production destroys data permanently — no undo.

Fix 8: Local Development

wrangler dev simulates DOs locally:

wrangler dev
# Local DOs persist to .wrangler/state/v3/do/

For remote DOs (test against actual Cloudflare):

wrangler dev --remote

For inspecting state:

# In another terminal:
sqlite3 .wrangler/state/v3/do/<your-do>/db.sqlite
sqlite> SELECT * FROM ...

Storage is backed by SQLite locally.

For tests, use Miniflare (the Wrangler-shared simulator):

import { Miniflare } from "miniflare";

const mf = new Miniflare({
  modules: true,
  scriptPath: "./dist/worker.js",
  durableObjects: { COUNTER: "Counter" },
});

const response = await mf.dispatchFetch("http://localhost/");

Common Mistake: Forgetting that local state persists between dev runs. To reset: rm -rf .wrangler/state/v3/do/.

Still Not Working?

A few less-obvious failures:

  • Cannot read properties of undefined (reading 'idFromName'). Binding not declared in wrangler.toml or wrong name. Verify [[durable_objects.bindings]] matches env property.
  • DO doesn’t receive requests. The default export’s fetch handler must explicitly route to the DO via env.X.get(id).fetch(request). Without forwarding, requests stop at the Worker level.
  • Cross-region latency. A DO lives in one region. If your traffic is global, the DO can be far from some users. Pick locationHint when creating IDs for known regions.
  • Transaction conflict. Two concurrent requests touched overlapping storage. The transaction API auto-retries; for non-transactional code, accept that storage is per-input-gate consistent.
  • Memory leaks. In-memory maps that aren’t bounded grow forever. Periodically prune or persist to storage and delete from memory.
  • Billing surprises. Each DO has a per-second duration cost. Long-lived stateful objects (like chat rooms) add up. Use hibernation for WebSocket-heavy DOs.
  • WebSocket reconnects don’t restore context. Clients reconnect → new connection → DO doesn’t auto-know it’s the same user. Track via auth or session cookie passed in the upgrade request.
  • Migrations between SQLite-backed and KV-backed DOs. They’re different storage backends. You can’t just rename. Spin up a new DO class with the new backend and migrate data.

For related Cloudflare and stateful edge issues, see Cloudflare D1 not working, Cloudflare R2 not working, Cloudflare Queues not working, and Cloudflare Pages not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles