How Sync Works
Subscriptions, deltas, and the sync protocol.
When you call useQuery, the client subscribes to a query on the server. The server sends the initial data, then pushes incremental deltas whenever the underlying documents change.
The subscription lifecycle
- Your component calls
useQuerywith a query name and arguments - The client sends a
Subscribemessage over the WebSocket (query name, args, last known sync version) - The server evaluates the query
- The server responds with a
SubscribeResponsecontaining the initial data and a sync version - The data writes to the client's local SQLite database (if SyncEngine is configured)
- React renders with the data
- The server registers the subscription for ongoing change detection
- When documents matching the query change on the server, it detects which subscriptions are affected
- The server sends a
Syncmessage with deltas (insert, update, or remove) - The client applies the deltas to local SQLite and React re-renders
Initial sync vs delta sync
On the first subscription, the server sends all matching documents as a full sync. The client stores them in local SQLite and records the sync version.
On reconnect, if the client sends a last_sync_version, the server sends only what changed since that version as a delta sync. This makes reconnection fast even with large datasets -- the client already has most of the data.
Delta operations
Each Sync message contains one or more deltas. Each delta describes a change to a single document:
| Operation | Meaning |
|---|---|
insert | A document now matches the subscription's sync filter |
update | A document that was already in the subscription changed |
remove | A document no longer matches the sync filter (deleted or filtered out) |
replace | Full dataset replacement (used for server-execution queries) |
Sync protocol
Messages are JSON over WebSocket. The current protocol version is PROTOCOL_VERSION = 4.
| Message | Direction | Purpose |
|---|---|---|
Hello | Server to Client | Sends protocol version on connect |
Auth | Client to Server | Sends JWT token for authentication |
AuthOk | Server to Client | Authentication succeeded (includes user ID, token expiry) |
AuthError | Server to Client | Authentication failed (includes error code) |
Subscribe | Client to Server | Starts a subscription (query name, args, last sync version) |
SubscribeResponse | Server to Client | Initial data for a subscription |
Sync | Server to Client | Delta updates for active subscriptions |
SyncWrite | Client to Server | Sends a mutation (handler name, args, captured environment) |
SyncWriteResult | Server to Client | Mutation confirmation or error |
Unsubscribe | Client to Server | Ends a subscription |
Connection flow
- Client opens a WebSocket connection
- Server sends
Hellowith its protocol version - Client sends
Authwith a JWT token (if authentication is configured) - Server responds with
AuthOkorAuthError - Client sends
Subscribemessages for each active query - Server responds with
SubscribeResponsefor each subscription - Ongoing: server sends
Syncmessages as data changes - Client sends
SyncWritemessages for mutations
Sync filters and membership
Each subscription has a sync filter that determines which documents belong to it. When a document changes, the server evaluates it against all active subscriptions for that table:
- Document now matches but was not a member -- the server sends an
insertdelta - Document still matches and was a member -- the server sends an
updatedelta - Document no longer matches but was a member -- the server sends a
removedelta
Sync filters are defined in your schema with the .sync() method on a table. They control which documents sync to which clients. For example, a sync filter of q.eq('userId', ctx.auth.userId) ensures each client receives only its own documents.
Conflict handling
Valet handles conflicts through two complementary mechanisms:
Rebase for offline mutations
When a client reconnects after being offline, Valet uses a rebase model for conflict resolution. Offline mutations replay against fresh server state on reconnect. Each mutation handler re-executes against current data, so read-then-write patterns (like incrementing a counter) produce correct results.
The rebase model addresses the offline case, where a client's mutations may be based on stale data. See Local-First Architecture for the full explanation of how rebase and deterministic replay work.
Optimistic locking for concurrent online writes
When multiple online clients attempt to update the same document concurrently, Valet uses optimistic locking via the _version field to detect conflicts. Each document has a _version that increments on every update. If a client attempts to update a document with a stale _version, the server returns a VERSION_CONFLICT error and the operation fails. The client must handle this error and retry with fresh data.
This prevents lost updates when multiple clients modify the same document simultaneously. Unlike rebase (which resolves conflicts by replaying operations), optimistic locking prevents conflicts by rejecting stale writes.
At the storage level, concurrent writes to different fields resolve via last-write-wins. Optimistic locking applies when clients explicitly specify a version in their write operations.