Durable Objects
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:
- Accept the socket with
state.acceptWebSocket(server, [tag])instead ofserver.accept(). - Implement
webSocketMessage/webSocketCloseas methods on the DO class — notaddEventListenercallbacks. - 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] bindingsexposes the class to your Worker under the nameSYNC_ROOM(accessed asenv.SYNC_ROOMto get a stub).[[migrations]]is a TOML array of tables; each entry has a uniquetagand the classes it introduces or renames.new_sqlite_classesmarksSyncRoomas a SQLite-backed DO. These migrations are applied bywrangler 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]]inwrangler.tomldeclares Durable Object class changes (creating a class, switching its storage backend, renaming, deleting). They are applied automatically atwrangler deploy. There is nowrangler durable-objects migrationscommand — you edit the TOML and deploy.wrangler d1 migrationsis 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.