A Cloudflare Worker can terminate a WebSocket connection directly. The harder architectural question appears after the upgrade succeeds: where does connection state live, which process coordinates multiple clients, and what remains valid when the runtime is no longer in memory?
Those are separate boundaries:
HTTP request
|
| Upgrade: websocket
v
Worker
|
+-- one independent connection
|
+-- shared room / session / presence
|
v
Durable Object
|
v
optional hibernationTreating all of them as “WebSocket support” hides the behavior that matters most in production.
The upgrade only creates the connection boundary
A Worker acting as a WebSocket server checks the HTTP upgrade request, creates a WebSocketPair, accepts the server side, and returns the client side in a 101 Switching Protocols response.
export default {
async fetch(request) {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
server.addEventListener("message", (event) => {
server.send("echo: " + event.data);
});
return new Response(null, {
status: 101,
webSocket: client,
});
},
};This is enough for an echo server or another connection whose useful state belongs entirely to that connection.
The pair itself does not create a shared coordination point. If two clients need to see the same room membership, sequence number, presence set, or game state, each connection cannot independently invent that state and still guarantee a coherent view.
Shared WebSocket state needs one coordination point
A chat room exposes the distinction clearly. Assume three clients connect:
client A ----client B -----+---- room state
client C ----/The room state may include:
connected clients
message ordering
presence
rate-limit counters
room metadataCloudflare Durable Objects provide a single addressable instance for that coordination boundary. A Worker can authenticate or route the request, derive a Durable Object ID from the room key, and forward the WebSocket upgrade to that object.
Conceptually:
Browser
|
| wss://
v
Worker
|
| room = "support-42"
v
Durable Object: support-42
|
+-- WebSocket A
+-- WebSocket B
+-- WebSocket CThe important property is not merely persistence. The object gives the room a single execution location for coordination. That makes operations such as broadcasting to current members or maintaining an ordered in-memory view tractable.
A standard Durable Object WebSocket can stay resident
Durable Objects support the Web Standard WebSocket API, but an accepted connection using the standard API keeps the object in a non-hibernatable state while that connection requires the object to remain active.
For a workload with many mostly idle clients, that lifecycle can dominate cost even when message volume is low.
The Hibernation WebSocket API changes that lifecycle. Instead of calling server.accept() inside the Durable Object, the object registers the socket with acceptWebSocket():
import { DurableObject } from "cloudflare:workers";
export class Room extends DurableObject {
async fetch(request) {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
const sessionId = crypto.randomUUID();
server.serializeAttachment({ sessionId });
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
async webSocketMessage(ws, message) {
const session = ws.deserializeAttachment();
ws.send(JSON.stringify({
sessionId: session.sessionId,
message,
}));
}
}The event model is different. Messages are delivered to methods such as webSocketMessage rather than an event listener attached with addEventListener.
That difference is part of the hibernation contract, not just an alternative syntax.
Hibernation keeps the socket, not JavaScript memory
When a Durable Object becomes eligible for hibernation, Cloudflare can evict its JavaScript instance from memory while keeping accepted WebSocket clients connected to the network.
A later WebSocket event recreates the object and runs its constructor before the event is delivered.
The lifecycle is therefore closer to:
client connected
|
v
Durable Object active
|
| idle and hibernatable
v
JavaScript instance removed
|
| socket remains connected
v
message arrives
|
v
constructor runs again
|
v
webSocketMessage(...)Anything stored only in ordinary JavaScript fields can disappear across that transition.
This makes an in-memory map useful as a cache, but unsafe as the only source of connection metadata that must survive hibernation.
Attachments reconnect socket identity to a new instance
serializeAttachment() associates structured-clone-compatible data with an accepted WebSocket. After hibernation, deserializeAttachment() can recover that data from the socket.
A small attachment can hold identifiers such as:
{
"sessionId": "2de9...",
"userId": "user-17",
"role": "member"
}That is different from durable application storage.
Attachments follow the WebSocket connection. They are appropriate for compact connection metadata needed when a new Durable Object instance reconstructs its working set. Data that must outlive the WebSocket itself belongs in Durable Object storage or another durable data store.
The distinction can be expressed as:
JavaScript field
-> fast working state
-> discarded on hibernation
WebSocket attachment
-> connection-scoped metadata
-> recoverable while the socket survives
Durable storage
-> application state
-> survives connection loss and object evictionTimers and outbound connections can defeat hibernation
Using the Hibernation API does not guarantee that an object will hibernate.
A Durable Object must be idle and free of activity that requires its JavaScript instance to remain available. Scheduled callbacks such as setTimeout or setInterval, in-progress work, the standard WebSocket API, and active outbound sockets can keep the object non-hibernatable.
That matters for heartbeat designs.
A server-side loop such as:
setInterval(() => {
sendPingToEveryClient();
}, 30_000);creates a reason for the JavaScript instance to remain resident. If the only purpose is a fixed ping/pong exchange, a hibernation-aware design should avoid waking the object merely to answer static heartbeat traffic.
Cloudflare exposes WebSocket auto-response support on Durable Object state for this class of message, allowing a matching request to receive a fixed response without waking a hibernating object.
Outbound WebSockets have a different lifecycle
Hibernation applies when the Durable Object is the WebSocket server for incoming clients. It does not make an outbound WebSocket hibernatable.
Consider a bridge:
browser
|
v
Durable Object
|
| outbound WebSocket
v
external serverThe incoming browser connection and the outgoing connection do not have identical lifecycle semantics. An active outbound WebSocket prevents normal hibernation behavior because the JavaScript instance participates in that live external connection.
This boundary matters for gateways, market-data relays, and protocol bridges. A design that is cost-efficient for thousands of idle inbound sockets may behave very differently once each object also maintains an outbound socket.
Choose the boundary from the state model
A direct Worker WebSocket is a good fit when the connection is self-contained and does not need a shared authoritative state holder.
A Durable Object becomes useful when a stable key such as a room ID, document ID, device ID, or session ID should map multiple events and clients to one coordination point.
The Hibernation API becomes important when those Durable Object WebSockets can remain idle for meaningful periods.
The resulting decision is structural:
Does the connection need shared coordination?
|
+-- no --> Worker WebSocket can be enough
|
+-- yes --> Durable Object
|
v
Can connections be idle?
|
+-- yes --> Hibernation API
|
+-- no --> active lifecycle may be acceptableWebSocket support in Cloudflare Workers is therefore not a binary capability. The upgrade, coordination, and runtime-lifetime boundaries are separate. Keeping them separate prevents connection-local code from becoming an accidental distributed state system and prevents long-lived but idle sockets from forcing JavaScript instances to remain resident unnecessarily.