Sync & optimistic updates
datadata uses optimistic updates: every change applies to the local view immediately, while the server remains the single source of truth. Editing keeps working while you’re offline — changes replay on reconnect — but a change isn’t durably committed until the server accepts it.
The life of a change
Section titled “The life of a change”Numbered for reading, not for timing: step 1 finishes before the server has heard anything, and the application spends steps 2 and 3 already rendering the change.
- A client calls
updateDocument. The change is applied to the local overlay instantly and sent to the server tagged with a client event id. - The server validates the change (schema, guards, limits), assigns the document’s next sequence number, persists it, and appends it to the document’s event log. (The sequence versions the JSON snapshot only — Yjs deltas travel in their own lane, under their own cursor.)
- The server broadcasts the change to every subscriber of that document. The originating client recognizes its own event id in the broadcast and retires the optimistic entry — the local view and the confirmed view now agree.
sequenceDiagram
accTitle: The life of an optimistic change
accDescr {
The app calls updateDocument. The client applies the change to its local
overlay at once, so reads reflect it immediately, and sends it to the
server while the app is already rendering it. If the server accepts, it
validates the change, assigns a sequence, persists it and broadcasts it to
every subscriber; the originating client recognises its own event id and
retires the optimistic entry. If the server rejects it, the client
discards the optimistic entry and reads fall back to confirmed state.
}
participant app as App
participant client as Client
participant server as Server
participant peers as Other subscribers
app->>client: updateDocument(...)
client-->>app: local reads reflect the change at once
client->>server: doc:update — patch + client event id
Note over app,client: the app renders the change<br/>while the round-trip is still in flight
alt server accepts
server->>server: validate · assign sequence<br/>persist · append to event log
server-->>peers: doc:patch
server-->>client: doc:patch — same client event id
client-->>app: optimistic entry retired —<br/>local and confirmed now agree
else server rejects — failed guard, validation, limit
server-->>client: doc:error — tied to the client event id
client-->>app: optimistic entry discarded —<br/>reads fall back to confirmed state
end
If the server rejects the change — a failed guard, a validation error — the client discards the optimistic entry and the local view falls back to the confirmed server state. The UI sees a clean signal, not corruption.
Every rejection carries a category, such as preconditionFailed,
schemaValidation or timeout, and DOCUMENT_ERROR_CATEGORY_TRAITS says how
to handle each one: whether trying again later can succeed (retryable),
whether the write may have committed after all, so the document should be read
again first (outcomeUnknown), and whether it points at a bug in the app
rather than anything the user did (appFault). An app branches on the traits
and keys only its wording by category, so a category added later needs no
change to its handling.
Reading
Section titled “Reading”getDocument returns the merged view: confirmed server state with any pending
optimistic changes overlaid. Subscriptions deliver an initial full snapshot
(doc:init) followed by incremental patches (doc:patch) — see the
wire protocol.
The initial snapshot doesn’t have to travel over the socket: a server-rendered page can fetch documents over plain HTTP and hand them to the client as preloaded state. The subscription then carries the preloaded sequence, and the server confirms with no data transfer — unless changes arrived between the HTTP fetch and the subscribe, in which case it sends a fresh snapshot. Either way, embedded Yjs documents catch up with exact state-vector diffs, not re-sent states.
Some of this state lives in system documents — subscribable documents the client, server, or session maintains rather than user data, such as the client’s own sync bookkeeping (subscription states, pending optimistic counts, errors) that the devtools panel renders. They’re read like any other document; see System documents.
What “optimistic” does not mean
Section titled “What “optimistic” does not mean”It hides latency and disconnects — but not durability
Section titled “It hides latency and disconnects — but not durability”Optimistic updates mask network latency, and they carry over a disconnection: writes made while the socket is down are held locally and replayed on reconnect — structured (JSON Patch) writes last-writer-wins, the Yjs lane by CRDT self-heal (deltas merge cleanly no matter how late they arrive). The boundary is durability, not connectivity: by default that replay buffer lives only in memory, so a page reload drops anything still unsent. Opt-in offline persistence is what extends the buffer to disk — a journaled write survives the reload and replays on the next boot.
Replay is de-duplicated, not silently lossy
Section titled “Replay is de-duplicated, not silently lossy”Re-sending a buffered write is safe to repeat: the server dedups by client event
id, so a write that committed but whose acknowledgement was lost can be re-sent
without applying twice. Dedup is time-bounded, though — a write left unconfirmed
too long is dropped and reported as unconfirmed (an awaited write rejects, a
fire-and-forget one hits onWriteError) rather than risking a silent
double-apply. The exact rules — the replay horizon, sequence-guard exemptions,
and the sweep that recovers a write left unanswered on a live connection — are in
Reconnect & replay.