# How WebSocket Support Works in Self-Hosted Durable Objects

> Learn how self-hosted Durable Objects in celld provide robust WebSocket support with hibernatable sockets and managed lifecycles for seamless client communication.

- Repository: [Deno/celld](https://github.com/denoland/celld)
- Tags: internals
- Published: 2026-08-15

---

**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`](https://github.com/denoland/celld/blob/main/crates/celld/js/harness.js) (lines 73-80), the `DurableObjectState.acceptWebSocket()` method registers the server-side socket with the host:

```javascript
// 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`](https://github.com/denoland/celld/blob/main/harness.js) (lines 87-115) allows registering patterns:

```javascript
// 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`](https://github.com/denoland/celld/blob/main/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`](https://github.com/denoland/celld/blob/main/crates/celld/js/websocket.rs) (lines 31-46). The runtime registers the socket and forwards operations to the host:

```javascript
// 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`](https://github.com/denoland/celld/blob/main/websocket.rs) (lines 68-91) which:

1. Builds the HTTP upgrade request
2. Performs the handshake using **fastwebsockets**
3. Creates an `OutboundWebSocketSink` for 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`](https://github.com/denoland/celld/blob/main/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`](https://github.com/denoland/celld/blob/main/websocket.rs) | Stores outbound frames while DO is hibernated |
| **Pre-registration buffering** | Lines 62-66 in [`js/websocket.rs`](https://github.com/denoland/celld/blob/main/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_message` verifies `Route::Local` (lines 32-33)
- **Outbound connections**: `outbound_websocket_task` confirms 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`](https://github.com/denoland/celld/blob/main/crates/celld/main/websocket.rs), [`crates/celld/js/harness.js`](https://github.com/denoland/celld/blob/main/crates/celld/js/harness.js), [`crates/celld/ws_client.rs`](https://github.com/denoland/celld/blob/main/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.