Changes as JSON Patch
Structured changes to a document’s JSON data travel as JSON Patch
(RFC 6902) — ordered lists of
add, remove, replace, move, copy, and test operations. Patches are
compact, human- and agent-readable, and double as the document’s audit trail
in the event log.
Guards
Section titled “Guards”A patch that was computed against one version of a document may not be safe to apply to a newer version. Writers choose how strict to be, per update:
- Sequence guard — “apply only if the document is still at sequence N.” The strictest mode: any concurrent change rejects the write.
- Patch guard — the patch carries RFC 6902
testoperations asserting the values it’s about to change. Concurrent changes to other parts of the document are tolerated; a failedtestrejects the write.
When generating a patch by diffing, test operations are emitted
automatically for replace and remove operations, so the patch defends
itself by construction. Additions are the gap — an add has no prior value to
assert, so guarded writes catch same-path changes and deletions but not two
writers creating the same new key (the
add precondition issue).
A rejected guard is expected, not an error — nothing to log and page on — but the write itself is genuinely dropped, not applied. The optimistic entry is rolled back, the client snaps to the winning state, and it’s on the caller to re-derive against that state and retry; nothing replays the lost write automatically. The staged session builds its conflict detection on the same primitive, turning that drop into a reviewable conflict instead.
Even an unguarded write can miss this way: if a concurrent write removed
the very path the patch targets (a replace whose entry is gone, an array
index past the end), the patch no longer resolves against the stored document.
The server applies patches with strict RFC 6902 validation and rejects that
drift with the same benign precondition-failed signal as a guard miss — rolled
back, document healthy, caller rebases — rather than surfacing an opaque
engine error.
Moving a value
Section titled “Moving a value”An update is written as the difference it made, so a value you take from one
place to another is sent as a remove and an add that carries your copy of
it. If someone else edited the value a moment earlier, your copy overwrites
their edit.
To move a value and keep what others changed in it, say that it is a move. The update callback’s third argument does that:
session.updateDocument({ docId, type: "board", callback: (doc, _yjsDocs, ops) => { ops.move("/todo/t1", "/done/t1"); doc.data.done.t1.title = "Shipped"; },});move takes two JSON Pointers
into doc.data. The draft changes at once, so the callback can go on editing
the value at its new place. The write is sent, stored and broadcast as a
move followed by the edits, and the server moves the value it holds.
- Move first, edit after. A value you changed before moving it is sent as the plain difference.
- Object members only.
movetakes a member of one object to a member of another, replacing what is there. It does not move array elements: that shifts the positions behind them, which is the problem the next section is about. - Your own unconfirmed edits come first. While an earlier edit of yours to the same value is still on its way to the server, the move is sent as the plain difference, which carries that edit with it.
- A move stays a move. Made offline, or sent again after a dropped
connection, it still goes out as a
move. There are two exceptions, and in both the move is sent as the plain difference. One is when a later edit of yours to the same value reached the server ahead of it, so that edit is not overwritten. The other is when the page reloaded, or the tab closed, before the move was sent. - A patch guard still guards. With
guard: "patch"the move carries atestof the value it moves, so a concurrent edit inside it rejects the write instead of being carried along. - On the server too. The callback of the server’s own
updateDocumenttakes the same third argument, and its move is stored and broadcast as amove. - Staged sessions stage the plain difference.
ops.movemoves the value in the draft there and nothing more.
Most of the time you do not need it. If a card’s column is a field of the
card and its position a fractional index, moving the card is one small
replace, and an edit to its title touches a different path. Reach for
move when where a value sits in the document is part of what it means.
Ordered collections without arrays
Section titled “Ordered collections without arrays”The array-addressing problem shapes how you model collections. A collection inside a document is stored as records keyed by id, each carrying a fractional index for ordering, rather than as a JSON array. This is deliberate: JSON Patch addresses array elements by position, which is unstable when several writers insert and remove concurrently. Fractional indices make “insert between A and B” a single-key write that doesn’t disturb neighbors. (This is one of the workarounds discussed in JSON Patch RFC issues.)
The order of an object’s keys is not part of the document, so don’t sort by it. A document is compared as a value everywhere, and nothing keeps its keys in the order they were added: the server and each client apply writes in their own order, and a reload hands a client the server’s copy. In a chat where two people post at once, one screen can list the messages in the order it saw them while the server’s copy has them the other way round. To show records in order, sort them by their fractional index, or by a timestamp or another field of their own.
Why patches — not CRDT operations, not domain events?
Section titled “Why patches — not CRDT operations, not domain events?”For structured data, datadata deliberately uses server-ordered patches with
guards rather than CRDTs — and rather than an application-defined event
vocabulary with reducers: a patch is self-applying data, so history can be
read and replayed in any context without the application’s code. The
trade-offs of both choices are discussed in
Design decisions. For collaborative text,
where convergence-per-keystroke matters, it uses
Yjs instead. Patches carry a real cost here
worth naming: a diffed edit to a text field is a whole-value replace, so each
change ships and stores the entire string — the
event log and the wire bloat fast for
large, frequently edited prose. Yjs sends compact per-edit deltas instead, so
for that shape of data it’s the better field type. The two travel together in
the same update.