Skip to content

Client-side DO client and subscriptions

The @repo/client-side-do client gives you two layers:

  • createDOClient owns the WebSocket connection, queueing, retries, and event fan-out.
  • createSubscriptionClient is a thin wrapper that instantiates the client and registers per-method handlers.

Think of the client as a reliable WebSocket RPC pipe with an opt-in event stream.

createDOClient maintains a single socket per Durable Object URL and handles three state buckets:

  • Pending requests: calls are stored in pending with a resolve/reject pair.
  • Queued payloads: if the socket is not open, requests (including subscribe/unsubscribe) go into queue up to maxQueueSize.
  • Subscriptions: a subscribers map of method name -> handlers, used to fan out events.

On reconnect, the client:

  • Re-sends pending payloads that were already sent before the disconnect.
  • Re-subscribes to all methods in subscribers.
  • Fires onReconnect handlers so you can re-bootstrap UI if needed.

This gives you a stable mental model: call RPC methods freely, and the client will deliver them once the socket is ready.

Each handler you register through subscribe is wired to a specific RPC method name. Event flow looks like this:

  1. subscribe(method, handler) sends a { type: 'subscribe', method } payload.
  2. The server stores the subscription on the socket attachment (see server doc).
  3. When a server emits an event, the client dispatches it to all handlers for that method.
  4. When the last handler is removed, unsubscribe is sent to the server.

The same approach works for subscribeAll, which uses method '*' to receive all events.

createSubscriptionClient returns a helper that builds the DO client and subscribes your handlers in one place:

const buildSubscriptionClient = createSubscriptionClient<InventoryRpc>({
baseUrl: buildInventoryWsUrl(inventoryId),
});
const doClient = buildSubscriptionClient(inventoryId, {
updateEntry: ({ result }) => {
// update local collection
},
});

You also get:

  • unsubscribe() to clean up handlers.
  • onStatusChange, onRetry, onReconnect for UI status or diagnostics.

You can define RPC result transforms once and pass the same transform profile to both createDOClient and createRpcServer. This is useful when you need custom serialization for response/event results (for example, better-result values).

const rpcTransforms = createRpcTransforms<InventoryRpc>()
.global({
name: 'better-result',
serialize: serializeBetterResult,
deserialize: deserializeBetterResult,
})
.done();
const buildClient = createDOClient<InventoryRpc>({
baseUrl: buildInventoryWsUrl(inventoryId),
resultTransforms: rpcTransforms,
});

In the inventory collection setup, the client wiring typically looks like this:

  • buildSubscriptionClient builds a createSubscriptionClient instance with a WebSocket URL per inventory.
  • A clientsById map keeps one client per inventory DO.
  • The collection uses createSubscriptionHandlers to transform incoming events into local collection changes.

The result: local state stays in sync with DO updates, with the client handling connection recovery under the hood.