zudo-cloudflare-wisdom

Type to search...

to open search from anywhere

Durable Objects

CreatedMay 28, 2026Takeshi Takatsudo

WebSocket Hibernation and SQLite-backed Durable Objects

A Durable Object (DO) is a single-instance, addressable Worker with its own persistent storage. Each named instance is a coordination point: every request for a given ID is routed to the same object, so it is the natural home for real-time fan-out (WebSockets), counters that must be exact, and any state that needs a single source of truth.

This page focuses on the two non-obvious parts of running a DO in production: the WebSocket Hibernation API and the SQLite-backed storage backend. It is grounded in a real sync server that broadcasts file changes to connected editors.

WebSocket Hibernation: re-find sockets, don’t hold them

The legacy DO WebSocket model used server.accept() plus addEventListener("message", ...), keeping the object pinned in memory for the life of every connection. The Hibernation API instead lets the runtime evict the object from memory between messages and rehydrate it on demand, while the WebSocket connections stay open. You pay only for active CPU time, not for idle connections.

The contract has three moving parts:

  1. Accept the socket with state.acceptWebSocket(server, [tag]) instead of server.accept().
  2. Implement webSocketMessage / webSocketClose as methods on the DO class — not addEventListener callbacks.
  3. Re-find the live sockets with state.getWebSockets(tag) every time you need them.
export class SyncRoom implements DurableObject {
  private state: DurableObjectState;

  constructor(state: DurableObjectState, _env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    // Handle WebSocket upgrade
    if (request.headers.get("Upgrade") === "websocket") {
      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);

      // Accept and tag with metadata for hibernation API
      this.state.acceptWebSocket(server, ["vault"]);

      const socketCount = this.state.getWebSockets("vault").length;
      log.info("WebSocket connected", { socketCount });

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

    // Handle POST /notify — called by file handlers to broadcast changes
    if (request.method === "POST") {
      const data = await request.json();
      this.broadcast(data);
      return new Response("ok");
    }

    return new Response("Not Found", { status: 404 });
  }

  // WebSocket Hibernation API handlers
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    try {
      const msg = JSON.parse(message as string);
      if (msg.type === "ping") {
        ws.send(JSON.stringify({ type: "pong" }));
      }
    } catch (error) {
      log.warn("malformed WebSocket message", {
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }

  async webSocketClose(_ws: WebSocket): Promise<void> {
    const socketCount = this.state.getWebSockets("vault").length;
    log.info("WebSocket disconnected", { socketCount });
  }

  private broadcast(data: unknown, exclude?: WebSocket): void {
    const sockets = this.state.getWebSockets("vault");
    const msg = JSON.stringify(data);
    let sentCount = 0;
    for (const ws of sockets) {
      if (ws !== exclude) {
        try {
          ws.send(msg);
          sentCount++;
        } catch {
          // Socket closed — hibernation API will clean it up
        }
      }
    }
    log.debug("broadcast", { recipients: sentCount, totalSockets: sockets.length });
  }
}

The upgrade handshake

A WebSocket upgrade is completed by hand. new WebSocketPair() returns two ends of a pipe: you keep the server end inside the DO and return the client end to the caller with the magic 101 Switching Protocols status and the webSocket field on the Response:

const pair = new WebSocketPair();
const [client, server] = Object.values(pair);

this.state.acceptWebSocket(server, ["vault"]);

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

The ["vault"] array is a list of tags. Tags let you group and later query sockets — here every connection in the room is tagged vault.

Why you must re-find sockets via getWebSockets(tag)

This is the rule that trips people up coming from the legacy model. Because the runtime can evict the object from memory between messages and rehydrate it later, anything you stash in an instance field (a Set<WebSocket>, a Map, a counter) does not survive. After rehydration the field is back to its constructor default, but the WebSocket connections are still open.

The only durable handle to the live connections is the runtime itself. Call state.getWebSockets("vault") every time you need the current set — at connect, at close, and on every broadcast:

const sockets = this.state.getWebSockets("vault");

⚠️ Don’t cache sockets in instance fields

A DO can be evicted from memory between messages and rehydrated on the next one. Instance fields are reset to their constructor values across that cycle, so a socket list you stored in this.sockets will be empty after rehydration even though the connections are alive. Always re-query with state.getWebSockets(tag) instead of holding references.

Broadcast swallows send errors on purpose

When fanning out a message, a socket may have closed in a way the object hasn’t been notified of yet. The broadcast loop deliberately swallows the send error rather than tracking liveness itself — the hibernation runtime is the source of truth and will reap the dead socket:

try {
  ws.send(msg);
  sentCount++;
} catch {
  // Socket closed — hibernation API will clean it up
}

SQLite-backed storage and the migrations trap

A DO needs its storage backend declared once, via a migration in wrangler.toml. The new_sqlite_classes directive registers the class against the SQLite-backed storage backend (the modern default, which also unlocks the synchronous state.storage.sql API):

[durable_objects]
bindings = [
  { name = "SYNC_ROOM", class_name = "SyncRoom" }
]

[[migrations]]
tag = "v1"
new_sqlite_classes = ["SyncRoom"]
  • [durable_objects] bindings exposes the class to your Worker under the name SYNC_ROOM (accessed as env.SYNC_ROOM to get a stub).
  • [[migrations]] is a TOML array of tables; each entry has a unique tag and the classes it introduces or renames. new_sqlite_classes marks SyncRoom as a SQLite-backed DO. These migrations are applied by wrangler deploy.

🚨 [[migrations]] is NOT wrangler d1 migrations

The word “migration” means two completely unrelated things in Cloudflare, and conflating them is a common trap:

  • [[migrations]] in wrangler.toml declares Durable Object class changes (creating a class, switching its storage backend, renaming, deleting). They are applied automatically at wrangler deploy. There is no wrangler durable-objects migrations command — you edit the TOML and deploy.
  • wrangler d1 migrations is a separate CLI workflow for versioned SQL schema changes to a D1 database (wrangler d1 migrations create / apply). It has nothing to do with Durable Objects.

A DO that stores data in SQLite is still configured through [[migrations]], not through wrangler d1 migrations.

When to reach for a Durable Object

  • Real-time fan-out — chat rooms, collaborative editors, live dashboards. One DO per room, every member’s WebSocket tagged and broadcast to.
  • Exact coordination — counters, rate limits, or locks that must be precise. KV is eventually consistent and will let a few requests slip through under concurrency; a DO is a single instance and serializes access.
  • Per-entity state — one object per user, document, or game session, each with its own private storage and single-threaded execution.

If you only need approximate, eventually-consistent values, KV is cheaper and simpler. Reach for a DO when “single source of truth” or “every connection sees this now” is a hard requirement.