Skip to content

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

FixDevs · (Updated: )

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.

A second layer of confusion comes from the two storage backends. The original KV-backed storage stores values in a CRDT-like log. The newer SQLite-backed storage (declared via new_sqlite_classes) gives you transactional SQL with the same single-writer guarantee but very different cost and capacity characteristics. Mixing the two within the same class boundary is not supported — once a DO class is created on one backend, you cannot migrate it in place to the other.

The third layer is the input gate model. Each request to a DO runs inside an “input gate” that serializes execution at the object level. Storage writes within the same input gate are buffered, then flushed atomically once the request returns. If a second request arrives mid-flight, it waits behind the first — meaning your in-memory this.cache = ... mutations cannot race, but only as long as you do not await foreign promises in the middle of a critical section. Awaiting fetch() to a third party releases the input gate and lets other requests interleave.

Diagnostic Timeline

A typical Durable Objects debugging session, minute by minute.

Minute 0. You deploy and the counter resets on every request. First guess: “wrong binding name in wrangler.toml.” You verify [[durable_objects.bindings]] name = "COUNTER" matches env.COUNTER in code. They match.

Minute 4. You suspect the binding is forwarding to the wrong region. You add console.log(env.COUNTER) and see a working DurableObjectNamespace. The binding is fine.

Minute 9. You look at the request path. Every handler invokes env.COUNTER.newUniqueId(). That call returns a fresh random ID per request, so every request hits a freshly-instantiated DO with empty storage. Swap to idFromName("global") and the counter persists.

Minute 15. New problem: the counter sometimes lags by one. Two parallel requests both read count = 5, both write count = 6. Even though DOs are single-threaded, your handler awaits a fetch between read and write, releasing the input gate. Wrap the read-modify-write in state.blockConcurrencyWhile(...) or move it into storage.transaction(...).

Minute 24. WebSocket clients silently drop after a minute. You assumed the old server.accept() API kept connections alive. It did, but billed for every second. You migrated to the Hibernation API but kept the in-memory clients: Set<WebSocket> field — hibernation wipes that field. Replace with state.getWebSockets(tag).

Minute 33. Alarm callbacks never fire on Wrangler local dev. Local Miniflare supports alarms but only after the DO is woken up by a real request. You add a manual fetch during the test to wake it, and alarms fire as expected.

Minute 41. A deleted_classes migration ran in production and wiped a beta dataset. Lesson learned: always test migrations against wrangler dev --remote against a staging Worker first.

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.
  • storage.put succeeds but storage.get returns the old value in the same handler. You forgot to await the put. The write is queued and not yet visible to the synchronous read that follows. Always await every storage call.
  • Alarm delivery is delayed by several minutes. Cloudflare guarantees at-least-once delivery, not punctual delivery. For high-precision scheduling, set the alarm slightly earlier than the target time and check Date.now() inside alarm() before acting.
  • WebSocket Hibernation messages stop arriving after a deploy. Existing connections survive a deploy, but they continue to invoke the old class methods until the connection drops. Add a version handshake so old clients reconnect after a deploy.

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