How WebSocket Support Works in Self-Hosted Durable Objects
Self-hosted Durable Objects in celld provide full WebSocket capabilities including hibernatable inbound sockets, auto-responses, and outbound client connections through a cell host that manages socket lifecycles independent of the DO's isolate state.
WebSocket support in celld enables self-hosted Durable Objects to handle real-time bidirectional communication while maintaining Cloudflare-compatible semantics. The implementation splits responsibilities between a JavaScript runtime API and a Rust-based host that manages socket persistence, routing, and hibernation—allowing sockets to survive even when the DO's isolate is idle.
Accepting Inbound WebSocket Connections
Inbound WebSockets begin when a client upgrades an HTTP request to the Durable Object. The DO must explicitly accept the connection through the runtime API.
Registering Sockets with acceptWebSocket()
In crates/celld/js/harness.js (lines 73-80), the DurableObjectState.acceptWebSocket() method registers the server-side socket with the host:
// From examples/wsecho/index.js
export default {
async fetch(request, env) {
const id = env.ECHO.idFromName("default");
const stub = env.ECHO.get(id);
return await stub.fetch(request);
}
};
export class Echo {
constructor(state, env) {
this.state = state;
this.env = env;
}
async fetch(request) {
const [server, client] = Object.values(new WebSocketPair());
// Line 14: Register with host for hibernation support
this.state.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
// Lines 17-22: Handle messages after wake
async webSocketMessage(ws, message) {
ws.send(message); // Echo back
}
}
The host creates a long-lived task per socket that outlives the original request, enabling hibernatable behavior. Each socket receives a numeric wsId and associates with a scope (the DO's address).
Auto-Response Optimization for Hibernation
To keep cells hibernated, celld supports auto-responses that answer frames without waking the DO. The setWebSocketAutoResponse API in harness.js (lines 87-115) allows registering patterns:
// Set before acceptWebSocket to enable hibernation
state.setWebSocketAutoResponse(
/ping/,
new Response("pong")
);
On the host side, dispatch_ws_message in crates/celld/main/websocket.rs (lines 18-26) checks for matching auto-responses before routing to the DO. If matched, the cell stays hibernated; otherwise, ws_message (lines 34-40) wakes the isolate to deliver the frame.
Lifecycle Management
When the DO closes or finishes processing, finish_websocket (lines 86-89) notifies the host to unregister the socket and clean up resources.
Creating Outbound WebSocket Connections
Durable Objects can initiate client connections to external services using the standard WebSocket constructor.
The WsPull Mechanism
Outbound sockets are established through the WsPull protocol defined in crates/celld/js/websocket.rs (lines 31-46). The runtime registers the socket and forwards operations to the host:
// From examples/wsclient/index.js (lines 13-26)
export class Client {
async fetch(request, env) {
// Open outbound connection
const ws = new WebSocket("wss://echo.websocket.org");
ws.addEventListener("open", () => {
ws.send("hello from celld DO");
});
ws.addEventListener("message", (event) => {
this.lastMessage = event.data;
ws.close();
});
return new Response("ok");
}
}
Host-Side Connection Handling
The host launches outbound_websocket_task in websocket.rs (lines 68-91) which:
- Builds the HTTP upgrade request
- Performs the handshake using fastwebsockets
- Creates an
OutboundWebSocketSinkfor message streaming
For local cells, messages route directly through the Self::Cell branch (lines 99-121). Remote cells stream over TCP. The anyhow::ensure!(route == Route::Local, …) validation (lines 32-33, 101-106) fails fast if the cell has migrated.
Frame Delivery
Outbound frames from ws.send() and ws.close() travel via the WsOut enum (lines 15-18) to the host, then over the TCP connection managed by ws_client.rs (handshake logic at lines 104-118).
Persistence, Hibernation, and Buffering
Socket ownership resides with the cell host, not the DO isolate. This architecture enables several critical behaviors:
| Mechanism | Implementation | Purpose |
|---|---|---|
| Pending frame buffering | WsRegistry lines 75-87 in websocket.rs |
Stores outbound frames while DO is hibernated |
| Pre-registration buffering | Lines 62-66 in js/websocket.rs |
Holds frames received before DO calls acceptWebSocket |
| Ordered delivery | Flush on wake | Guarantees exactly-once, in-order message delivery |
When the DO wakes—triggered by a message, alarm, or auto-response match—buffered frames flush to the runtime preserving temporal ordering.
Routing and Ownership Validation
All sockets bind to a scope (the DO address). The host validates routing before message delivery:
- Inbound messages:
dispatch_ws_messageverifiesRoute::Local(lines 32-33) - Outbound connections:
outbound_websocket_taskconfirms local residence (lines 101-106)
If a cell has migrated to another node, these checks fail with a clear error—preventing split-brain scenarios and ensuring correct message routing in distributed deployments.
Summary
- Inbound WebSockets use
state.acceptWebSocket()to register hibernatable sockets with auto-response optimization - Outbound connections leverage
new WebSocket()with WsPull protocol and fastwebsockets handshake - Host ownership enables socket survival across DO hibernation cycles with guaranteed ordered delivery
- Scope-based routing validates local cell residence to prevent message misdirection
- Key implementation files:
crates/celld/main/websocket.rs,crates/celld/js/harness.js,crates/celld/ws_client.rs
Frequently Asked Questions
What makes celld WebSockets "hibernatable"?
Sockets are owned by the cell host, not the JavaScript isolate. When a DO finishes processing, its isolate can hibernate while the host keeps the TCP connection alive. Incoming messages either match an auto-response (keeping the DO asleep) or trigger a wake event to deliver buffered frames. This matches Cloudflare's hibernation semantics while running on self-hosted infrastructure.
How does auto-response differ from normal message handling?
Auto-responses are regex patterns registered via setWebSocketAutoResponse() that the host evaluates in dispatch_ws_message before waking the DO. If a text frame matches, the host responds immediately without isolate invocation. Normal message handling requires waking the DO and calling its webSocketMessage handler. Auto-responses are ideal for health checks, heartbeats, or simple acknowledgments.
Can outbound WebSockets connect to other Durable Objects in the same cluster?
Yes. When the target address resolves to a local cell, outbound_websocket_task routes through the Self::Cell branch for direct intra-cluster communication. If the target DO has migrated, the routing validation fails with an error—applications should retry through the appropriate location hint or external load balancer.
What happens to messages sent while the DO is hibernated?
The host buffers both directions: WsRegistry stores outbound frames generated by a waking DO, while pre-registration buffering holds inbound frames before acceptWebSocket() completes. On wake, frames flush in order with exactly-once delivery guarantees. The buffer sizes and timeouts are implementation-defined in the host configuration.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →