> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deep.space/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](/concepts/permissions), persists it to SQLite, and broadcasts the update to every other connected client within milliseconds.

Each [`RecordRoom`](/sdk-reference/worker/rooms#recordroom\<e>) 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`](/sdk-reference/client/records#usemutations\<t>-collection) 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.

```tsx theme={null}
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`:

1. Generates a `recordId` client-side and sends a `core.put` message over the WebSocket
2. The DO verifies RBAC, writes to SQLite, and broadcasts `core.record_change` to every connected client whose RBAC allows it (including the sender)
3. The client store applies the change against any active [`useQuery`](/sdk-reference/client/records#usequery\<t>-collection-options) subscriptions 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`](/sdk-reference/worker/rooms#recordroom\<e>) syncs to every connected client whose RBAC allows it.

You can mount additional scopes by nesting [`<RecordScope>`](/sdk-reference/client/records#recordscope):

```tsx theme={null}
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 `put`s 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 `*Confirmed` calls 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 - use `createConfirmed` / `putConfirmed` / `removeConfirmed` when 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](/concepts/permissions#visibilityfield-and-collaboratorsfield) for the full visibility model.

## Other room types

The same WebSocket pattern applies to the other DO types exported from [`deepspace/worker`](/sdk-reference/worker/rooms), but the wire vocabulary differs:

* [`YjsRoom`](/sdk-reference/worker/rooms#yjsroom\<e>) speaks the Yjs sync protocol. The DO holds the canonical Y.Doc and broadcasts updates.
* [`CanvasRoom`](/sdk-reference/worker/rooms#canvasroom\<e>) stores shapes in a Y.Doc `Y.Map` and sends typed shape and viewport messages on top, optimized for high-frequency cursor and shape updates.
* [`PresenceRoom`](/sdk-reference/worker/rooms#presenceroom\<e>) is fire-and-forget - peer state is held in memory only, never persisted. Updates broadcast at full speed.
* [`GameRoom`](/sdk-reference/worker/rooms#gameroom\<e>) runs an authoritative tick loop on the DO, broadcasting state snapshots to all players.

Use [`useQuery`](/sdk-reference/client/records#usequery\<t>-collection-options) / [`useMutations`](/sdk-reference/client/records#usemutations-t-collection) for durable, RBAC-filtered records. Use the room-specific hooks - [`useYjsText`](/sdk-reference/client/realtime#useyjstext-collection-recordid-fieldname), [`useCanvas`](/sdk-reference/client/realtime#usecanvas-roomid), [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid), [`useGameRoom`](/sdk-reference/client/realtime#usegameroom-roomid) - 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_result` snapshot.
* In-flight `*Confirmed` calls 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`](/sdk-reference/client/records#usequery\<t>-collection-options)'s `status` returns `'loading' | 'ready' | 'error'`; [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) exposes a `connected` boolean).

## Next steps

* [Data storage](/guides/data-storage) - define a schema and wire up CRUD.
* [Presence and cursors](/guides/presence-and-cursors) - real-time presence with `usePresenceRoom`.
* [Collaborative editing](/guides/collaborative-editing) - Yjs-backed text and shapes.
* [Records reference](/sdk-reference/client/records) - the full hooks API.
