Two people open the same canvas. One drags a rectangle, the other recolors a circle, both while a third person is offline on a train editing the same file. When everyone syncs, the document has to agree on one result, nobody's work can vanish, and you still want a history you can scrub back through. The lazy answer is a server lock: one editor at a time, everyone else read-only. It is easy to build and miserable to use.
I want to walk through the data model that makes the hard version work, the one behind real-time collaboration, offline editing, and version history in Wunjo Design. None of this is framework magic. It is one idea applied carefully, and you can reason about all of it from first principles. I will keep the code to small, generic snippets so the shape is clear.
Multiplayer editing looks like a UI feature. It is really a question about how two histories merge into one. Photo: Unsplash.
The real problem: merging two edits with no referee
Picture the document as a plain object: shapes keyed by id. Now two clients change it at the same time, each against their own copy. You receive both changes out of order, maybe minutes apart. What is the final state?
If you just replay "last write wins" on whole objects, the recolor clobbers the move, or the other way around, depending on which packet arrived last. Work disappears. If you funnel every edit through a central server that serializes them, you are back to a lock with extra steps, and offline editing is impossible because there is no server to ask.
The question underneath multiplayer is not "how do I send changes over a socket." It is "given two divergent versions of the same document, how do I merge them so that every client lands on the identical result, no matter what order the changes arrive in."
Two families of answer
There are two well-known approaches.
Operational Transformation (OT) is what classic Google Docs uses. Each edit is an operation, and when operations conflict you transform one against the other so they still compose. It works, but the transformation functions are notoriously fiddly, and most designs lean on a central server to order operations.
Conflict-free Replicated Data Types (CRDTs) take a different route. You design the data structure so that merging is commutative, associative, and idempotent by construction. Apply the same set of changes in any order, any number of times, and every replica converges to the same state. Shapiro and colleagues formalized this as strong eventual consistency in 2011. The practical upshot: no central referee is required. Any peer can merge any other peer's changes and they all end up equal.
I went with the CRDT route because it gives offline editing and history for free instead of as extra subsystems. The algorithm behind the JavaScript CRDT libraries in this space (Yjs, via the YATA algorithm) is built exactly for editing arbitrary structured data in a browser.
Modeling a canvas as shared types
The trick is to stop thinking of the document as one big blob and model it as nested CRDT containers: a map of shapes, where each shape is itself a map of properties. A minimal sketch with the Yjs API:
import * as Y from 'yjs'
const doc = new Y.Doc()
const shapes = doc.getMap('shapes') // the whole scene
function addRect(id) {
const rect = new Y.Map()
rect.set('type', 'rect')
rect.set('x', 10); rect.set('y', 20)
rect.set('w', 100); rect.set('h', 60)
rect.set('fill', '#3366ff')
shapes.set(id, rect)
}
Granularity is the whole design decision here. Because each shape is its own map, two people editing different shapes never touch the same data, so there is nothing to conflict. And because each property is its own key, one person moving a rectangle (x, y) while another recolors it (fill) merges cleanly: the move and the recolor are independent keys, both survive. You only get a real conflict when two people set the same property of the same shape at the same instant, and there the CRDT picks a deterministic winner that every client agrees on.
Sync is just bytes you can apply in any order
Once the document is a CRDT, "sync" stops being a protocol you design and becomes two function calls. A client serializes its changes to a compact binary update and ships it. Any peer applies it. Order does not matter.
// on the client that made changes
const update = Y.encodeStateAsUpdate(doc) // a small binary diff
// on any other client, whenever it arrives
Y.applyUpdate(otherDoc, update) // merge; commutative and idempotent
That applyUpdate is the entire merge engine. Apply the same update twice, it is a no-op. Apply two clients' updates in opposite orders on two machines, both machines match. This is the property that makes the network boring, which is what you want a network to be.
Offline falls out for free
Here is the part I find satisfying. Offline editing is not a separate feature you bolt on. When a client is offline, it keeps producing updates against its local document. When it reconnects, those queued updates merge with everyone else's through the same applyUpdate call. There is no special "sync conflict resolution" screen, because the data type cannot produce an unresolvable conflict. This is the local-first idea that Kleppmann and colleagues argued for in 2019: the network is an enhancement, not a requirement, and your data lives on your device first.
History without saving fifty copies
Version history sounds like it needs a pile of saved files. It does not. A CRDT document already carries the information needed to reconstruct earlier states, because it is fundamentally a log of changes plus enough metadata to order them. You take lightweight snapshots at points worth remembering, and you can reconstruct or restore the document as of any snapshot.
const checkpoint = Y.snapshot(doc) // a cheap marker, not a full copy
// later: rebuild the document state as of `checkpoint` for preview or restore
So "version history with rollback" becomes: keep a list of snapshots with labels, render a preview from any of them, and to restore, apply the delta that takes you back. You are storing operations and markers, not duplicate documents, which is why the history can be deep without bloating storage.
![]() |
About the author. I'm Wlad Radchenko, a software engineer. This article draws on building Wunjo Design, a browser-based AI vector editor with collaboration and version history, and the open-source Wunjo Make. Get in touch to find more on GitHub and LinkedIn. |
Things that bite you in practice
A few lessons that cost me time.
Keep transient state out of the document. Cursors, selections, and who-is-online change many times a second and mean nothing tomorrow. Put them on an ephemeral presence channel (Yjs calls it awareness), not in the CRDT, or your history fills with mouse twitches.
Do not store images inside the CRDT. Big binary blobs make every sync and every snapshot heavier. Store the blob separately, keyed by id, and keep only the reference in the document.
Undo has to be per-user and merge-aware. In a shared document, your undo must reverse your last action, not your collaborator's. A naive global undo stack will happily undo someone else's work. The CRDT libraries track the origin of each change so undo can be scoped, and that is its own topic, which I will take apart next.
Compact the history. An append-only log grows. Periodic snapshots plus garbage collection of superseded operations keep it bounded.
What this buys the product
One data model gives Wunjo Design three features that usually cost three subsystems: real-time collaboration, offline editing, and version history you can roll back. They are not separate engineering efforts stapled together. They are consequences of choosing a data structure that merges by construction. That is the kind of decision that does not show up in the UI but determines whether the UI is even possible.
If you are building anything collaborative in the browser, start from the data model, not the socket. Pick a structure where merge is total, and the network, the offline support, and the history mostly write themselves.
References
- Shapiro, Preguiça, Baquero, Zawirski. "Conflict-free Replicated Data Types." SSS 2011. HAL
- Nicolaescu, Jahns, Derntl, Klamma. "Near Real-Time Peer-to-Peer Shared Editing on Extensible Data Types." ACM GROUP 2016. (The YATA algorithm behind Yjs.)
- Kleppmann, Wiggins, van Hardenberg, McGranaghan. "Local-first software: you own your data, in spite of the cloud." ACM Onward! 2019. PDF

Top comments (0)