Real-time sync
How DeepSpace synchronizes data across clients using WebSockets and Durable Objects.
DeepSpace syncs data between clients over a persistent WebSocket connected to a Durable Object. This page covers the wire protocol, how writes propagate, and the consistency guarantees the SDK provides.
The model#
Every client holds a local replica of the records it has subscribed to. When any client mutates a record, the DO validates the write against permissions, persists it to SQLite, and broadcasts the update to every other connected client within milliseconds.
Each RecordRoom is the single source of truth for its data. The DO owns its SQLite database and serializes all writes, so there are no merge conflicts to resolve.
The mutation pipeline#
useMutations returns three mutation functions - create, put, remove - plus a *Confirmed variant of each. The plain functions are fire-and-forget over the WebSocket; the *Confirmed variants await a server ACK and reject on failure.
import { useMutations } from 'deepspace'
const { create, put, remove, createConfirmed } = useMutations<Task>('tasks')
// Fire-and-forget. Returns the new recordId immediately.
// The local store updates when the server echoes the change back.
const id = await create({ title: 'New task', completed: false })
// Awaits the server ACK. Rejects if RBAC denies the write.
const id2 = await createConfirmed({ title: 'Important', completed: false })
A call to create:
- Generates a
recordIdclient-side and sends acore.putmessage over the WebSocket - The DO verifies RBAC, writes to SQLite, and broadcasts
core.record_changeto every connected client whose RBAC allows it (including the sender) - The client store applies the change against any active
useQuerysubscriptions and re-renders
There's no local-optimistic apply step - the UI updates when the broadcast comes back. In practice the round-trip to a colocated Durable Object is single-digit milliseconds, so it feels instant. Use the *Confirmed variants when you need to know the server accepted the write - typically to surface a permission error inline, or to wait on a server-validated outcome before navigating.
Subscription scope#
A client subscribes to one or more scopes (Durable Object instances). The default scope in the scaffold is app:<APP_NAME> - every record in your app's main RecordRoom syncs to every connected client whose RBAC allows it.
You can mount additional scopes by nesting <RecordScope>:
import { RecordScope } from 'deepspace'
import { CONVERSATION_SCHEMAS } from './schemas'
import { APP_NAME } from './constants'
<RecordScope
roomId={`conv:${convId}`}
schemas={CONVERSATION_SCHEMAS}
appId={APP_NAME}
>
<ChatThread />
</RecordScope>
Each scope is an independent DO with its own WebSocket. Subscriptions don't interfere with each other.
What the DO sends#
When a client calls useQuery, the SDK sends a core.subscribe message. The DO replies with a core.query_result snapshot containing every record that matches the query and passes the caller's read check. From that point forward, the DO pushes incremental updates as core.record_change messages, each carrying a changeType: 'create' | 'update' | 'delete' discriminator alongside the record envelope.
The client store applies each change against every active subscription. An update can move a record into or out of a query's where clause, so the same changeType: 'update' can mean different things to different subscriptions:
| Wire message | Effect on a subscription |
|---|---|
changeType: 'create', record matches where |
Record is added to the result set |
changeType: 'update', record matches and was already in the set |
Record is updated in place |
changeType: 'update', record now matches but wasn't in the set |
Record is added (treated as a create) |
changeType: 'update', record no longer matches and was in the set |
Record is removed (treated as a delete) |
changeType: 'delete' |
Record is removed if present |
Consistency guarantees#
- Writes are serialized inside the DO. Two concurrent
puts land in a deterministic order - the second wins on field-level merge. - The DO sees all writes before any client. There is no eventual consistency window from the DO's perspective.
- WebSocket disconnects trigger an automatic reconnect. Active subscriptions re-subscribe and receive a fresh snapshot; the client store reconciles silently.
- In-flight
*Confirmedcalls reject if the socket drops with'WebSocket disconnected'; calls made while already offline reject with'WebSocket not connected'. Fire-and-forget mutations sent while disconnected are silently dropped - usecreateConfirmed/putConfirmed/removeConfirmedwhen you need delivery guarantees.
Permissions on the wire#
Permissions are enforced before the DO broadcasts. A user without read access to a record never sees it on the wire, so client-side filters are not a security boundary - they're a usability concern.
If a schema declares a visibilityField, the DO re-evaluates read access on every update. When a record's value at that field transitions to the configured "visible" value (default: 'public'), the DO broadcasts a core.record_change to clients that gain read access at that moment. The inverse also happens: making a record private produces a changeType: 'update' that the client store interprets as a delete for users who lose access. See permissions for the full visibility model.
Other room types#
The same WebSocket pattern applies to the other DO types exported from deepspace/worker, but the wire vocabulary differs:
YjsRoomspeaks the Yjs sync protocol. The DO holds the canonical Y.Doc and broadcasts updates.CanvasRoomstores shapes in a Y.DocY.Mapand sends typed shape and viewport messages on top, optimized for high-frequency cursor and shape updates.PresenceRoomis fire-and-forget - peer state is held in memory only, never persisted. Updates broadcast at full speed.GameRoomruns an authoritative tick loop on the DO, broadcasting state snapshots to all players.
Use useQuery / useMutations for durable, RBAC-filtered records. Use the room-specific hooks - useYjsText, useCanvas, usePresenceRoom, useGameRoom - for CRDT text, canvas shapes, presence, or a server-authoritative tick loop.
Disconnection and reconnection#
The SDK handles WebSocket lifecycle transparently:
- On disconnect, the local store stays intact - UI keeps working with the last known snapshot.
- The SDK reconnects with exponential backoff (capped at 30s) and on tab refocus.
- On reconnect, every active subscription re-subscribes and receives a fresh
core.query_resultsnapshot. - In-flight
*Confirmedcalls reject with'WebSocket disconnected'; calls made after the socket has closed reject with'WebSocket not connected'. Fire-and-forget calls made while offline are dropped - the SDK does not queue them.
You can observe the connection state on each hook (useQuery's status returns 'loading' | 'ready' | 'error'; usePresenceRoom exposes a connected boolean).
Next steps#
- Data storage - define a schema and wire up CRUD.
- Presence and cursors - real-time presence with
usePresenceRoom. - Collaborative editing - Yjs-backed text and shapes.
- Records reference - the full hooks API.