Skip to content

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.

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 test operations asserting the values it’s about to change. Concurrent changes to other parts of the document are tolerated; a failed test rejects 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.

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. move takes 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 a test of 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 updateDocument takes the same third argument, and its move is stored and broadcast as a move.
  • Staged sessions stage the plain difference. ops.move moves 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.

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.