Developers
Sync protocol
How edits flow between clients and Convex — sequence numbers, commits, the confirmed-plus-pending client model, loading, snapshots and presence.
On this page
Convex is the authority for every design. Clients edit optimistically and converge on the server's order. The rules below are part of the frozen contract and apply to the web, desktop and iOS apps, the AI and the Agent API alike.
- Authority: Convex. Each design has a monotonically increasing
seq.designOpstable holds accepted transactions byseq. Materialized state lives in node documents; periodic snapshots (every 200 tx or 5 min of activity) are stored as compressed JSON in S3 (snapshots/<designId>/<seq>.json.zst) and referenced fromdesigns.snapshot. - Client commit:
designs.commit({ designId, baseSeq, tx })->{ seq, applied, dropped }. Server applies ops to the current state (not tobaseSeq) with last-writer-wins per property; structural conflicts resolved by server order; invalid ops are dropped and reported. Idempotent ontx.id. - Client model:
confirmed(design at last server seq) +pending(own uncommitted transactions). Render = confirmed + pending. On remote tx: apply toconfirmed, then re-applypendingover it (dropping what no longer applies). On own ack: move the tx from pending into confirmed (using the server'sapplied). - Load:
designs.load(paginated one-shot queries, nodes in chunks of 500) returns{ design, seq }; then subscribe todesignOps.since({ designId, seq })(reactive) for live transactions. If the gap is large the client reloads a snapshot. - Ephemeral state (cursors, selections, live drag previews, "AI is editing X" markers) uses
presence(Convex table withexpiresAt, heartbeat every 5 s, auto-cleanup cron) and never enters history. - Dragging/typing never commits per frame. Clients commit one transaction per gesture (pointer-up / debounced text edit 400 ms).
- AI/workflow transactions use the same
commitwithsource: "ai" | "workflow"; the client groups consecutive transactions with the sameactor.runIdinto one undo step.
Sequence diagram#
A typical edit from one client while another is watching:
Client A Convex Client B
│ apply tx locally (pending) │ │
│── designs.commit(baseSeq, tx) ─▶ apply to current state │
│ │ seq = seq + 1, log in designOps │
│◀──── { seq, applied, dropped } ─│ │
│ move tx: pending → confirmed │── designOps.since(seq) update ─▶│
│ │ apply to confirmed, │
│ │ re-apply own pending │Implementing a client#
- Load: call
designs.loadpage by page, assemble theDesign, rememberseq. - Subscribe to
designOps.since({ designId, seq })and apply each incoming transaction to the confirmed state, then re-apply your pending transactions on top (dropping ops that no longer apply). - Edit: build a transaction per gesture, apply it locally as pending, and call
designs.commit({ designId, baseSeq, tx }). - Acknowledge: when the commit returns, replace the pending transaction with the server's
appliedops and advance to itsseq. - Recover: if the subscription reports a large gap, reload (snapshot plus subsequent transactions).
@desyn/doc-ops provides this state machine — createSyncState, commitLocal, acknowledge, receiveRemote and rebase — so TypeScript clients only wire it to the transport. Over plain HTTP, the Agent API exposes the same commit and since operations.
Presence#
Cursors, selections, live drag previews and "AI is editing" markers go through the presence table: clients heartbeat every 5 seconds, entries expire on their own, and a cron removes stale ones. Presence is never written into the design or its history.