Understanding sync engines: How Figma, Linear, and Google Docs work
Learn how different realtime sync engines work, dive into the details of Yjs and CRDTs, and discover which collaboration infrastructure is right for your application.
Two users edit a rectangle in your design tool while offline. Alice moves it to
position [100, 200]. Bob changes its color to red. When they reconnect, what
should happen? This is easy to answer—show the rectangle at [100, 200] in
red. Both changes survive.
Now the hard questions:
What if they both moved it to different positions?
What if Alice deleted the rectangle while Bob moved it?
What if they're both typing at the same position in a text field?
Your answers to these questions determine your entire sync architecture.
Choose wrong, and you're looking at 3-6 months of refactoring when you realize
users are losing data.
Every modern app is adding “realtime collaboration” to their feature list, and
you've probably heard CRDT thrown around like it's a silver bullet. The reality
is far more nuanced: Figma’s collaborative canvas works fundamentally
differently than Google Docs’ text editing, which works differently than
Linear's issue tracking. They’re all “realtime” and “collaborative”, but the
underlying sync mechanisms solve different problems.
Every realtime collaborative system must be able to handle multiple users
editing the same data simultaneously, with network delays and offline periods.
Changes can arrive in any order, and everyone needs to end up with the same
result.
Let’s think back to Alice and Bob’s rectangle, and some conflicts they’ll face
in a multiplayer app:
Alice and Bob change different properties? Easy. Both changes survive.
They edit the same property differently? Tricky. A conflict resolution
strategy is needed.
Alice deletes, Bob modifies? Tricky. An intention conflict needs to be
resolved.
Both typing in the same text position? Difficult. This is where most naive
implementations break.
There are two solution families that handle these scenarios differently.
Operational Transformation (OT) transforms operations based on what others did
concurrently. Requires a central server to order operations, and is used by
Google Docs, Microsoft Office Online. Complex to implement correctly, but gives
predictable results for text editing.
Conflict-free Replicated Data Types (CRDTs) operations are mathematically
commutative—apply them in any order, always converge to the same state. Works
peer-to-peer or client-server. True CRDTs underpin frameworks like Yjs and
Automerge, powering many collaborative text editors and document tools.
Both enable “realtime collaboration”, but they’re designed for fundamentally
different problems. Figma’s rectangle positioning needs different conflict
resolution than Google Docs’ text editing. The key insight is that it’s not
about OT vs CRDT—it’s about property-level vs character-level conflict
resolution.
Property-level conflict resolution treats each attribute of an object as an
independent unit. When two users edit different properties of the same object,
both changes succeed. When they edit the same property, Last Write Wins (LWW)
based on a logical clock.
Character-level conflict resolution treats text as a sequence of characters
with positional metadata. When two users edit different parts of the same text,
both edits are preserved in the final result through sophisticated merging
algorithms.
Property-level uses last-write-wins per property, where character-level
merges based on position.
// Both changes survive because they modified different propertiesconst task ={ title:"Buy apples and oranges",// User A's change done:true,// User B's change};
This is property-level conflict resolution, used by Figma and Liveblocks
Storage. Each property is independent, and changes to different properties never
conflict.
Now consider the same scenario with text editing:
Buy apples
Buy apples `and oranges`
Buy apples `and bananas`
Buy apples `and oranges and bananas`
This is character-level conflict resolution, used by Google Docs and Yjs.
The CRDT tracks the position and context of each character insertion, allowing
both edits to coexist in the final document. This difference determines your
entire sync architecture.
The theory is interesting, but what do real companies with millions of users
actually do? Let’s examine three battle-tested systems.
Figma’s servers track the latest value for each property on each object, as
described on
Figma’s engineering blog.
When two clients change different properties (one moves the rectangle, another
changes its color), both changes succeed. When two clients change the same
property, the last change received by the server wins.
“Figma's multiplayer servers keep track of the latest value that any client has
sent for a given property on a given object. This means that two clients
changing unrelated properties on the same object won't conflict.”
Evan WallaceCo-founder, Figma
This is explicitly not a true CRDT because it relies on a central server for
ordering, but it’s inspired by CRDT concepts—specifically the
Last-Writer-Wins Register pattern.
Liveblocks Storage uses this same proven architecture with centralized CRDT-like
structures. I’ve implemented similar patterns in production social platforms—the
simplicity is what makes it reliable at scale. Figma chose this approach
because:
Reason
Impact
Design tools work with discrete objects
Each shape, layer, or component is an independent entity
Properties update independently
Color changes don’t conflict with position changes
Users rarely edit same property simultaneously
Natural work patterns reduce conflicts
Last-write-wins is intuitive
When conflicts occur, newest wins matches user expectations
Significantly simpler than full CRDTs
Faster performance, easier to debug
Figma’s approach doesn’t work for text editing. If the text “B” becomes “AB” on
one client and “BC” on another, the result is either “AB” or “BC”—never “ABC”.
This is fine for design tools but unacceptable for collaborative text editors.
Linear’s sync engine is designed for a more varied data model than Figma (as
documented in their blog
and
reverse-engineered here)
and uses a similar property-level approach for most data:
Linear tracks changes as discrete events, each modifying a single attribute. The
server assigns each change a monotonically increasing sync ID, and conflicts
resolve to the highest ID (effectively last-write-wins, but server-ordered).
Linear Design Choice
Rationale
Event-based architecture
Every change is a separate event with metadata
Property-level granularity
Status changes don't conflict with title edits
Server-ordered sync IDs
Reliable ordering through centralized transaction processing
Last-Write-Wins default
Conflicts are rare; simple resolution works
CRDTs only for rich text
Added later for issue descriptions only
According to
their engineering team's discussions,
conflicts are actually quite rare in their domain—most of the time users are
working on different issues or different properties of the same issue. They only
recently added CRDTs for one specific use case; rich-text editing in issue
descriptions.
Both Figma and Linear use property-level approaches because they work with
discrete objects where conflicts are rare. Text editing is fundamentally
different—users constantly insert characters at nearby or identical positions.
This is where character-level resolution becomes essential.
Google Docs and Yjs both handle character-level text editing, but use different
approaches. Google Docs uses Operational Transformation (server-based), while
Yjs uses CRDTs (peer-to-peer). Both recognize that text editing has
fundamentally different conflict patterns than object properties.
Here’s how Yjs structures text for CRDT-based collaboration:
typeYjsText=Array<{ char:string; id:{ client:string; clock:number;}; left:ID|null;// Previous character right:ID|null;// Next character}>;
Every character gets a unique identifier tracking its position and creation
context. When two users type at the same spot simultaneously, Yjs uses
a deterministic algorithm to decide
which text comes first based on these identifiers:
`Hello world`
`Hi there`
`Hello world` OR `Hi there`
`Hi Hello world` OR `HelloHi there`
This is fundamentally different from property-level sync. Yjs operates on
ordered sequences—treating text as individual characters with positional
relationships. You can’t just “swap in Yjs” for a Figma-like app because it’s
solving a different problem: preserving the order and intent of concurrent
insertions in sequences, not resolving conflicts between discrete object
properties.
You’ve seen what Figma, Linear, and Google Docs do, now let's determine which
approach fits your application.
LiveObject is a CRDT-like
data structure that allows these changes to be merged automatically.
import{LiveObject}from"@liveblocks/client"; // Create a shape, each property is independently mutableconst rectangle =newLiveObject({ type:"rectangle", x:0, y:200, fill:"blue",}); // User A moves the rectanglerectangle.set("x",50); // User B changes the color (simultaneously)rectangle.set("fill","red"); // Both changes survive, no conflicts// {// type: "rectangle",// x: 50,// y: 200,// fill: "red",// }rectangle.toImmutable();
It’s also possible to use
LiveMap and
LiveList to manage complex
data structures, nesting them inside each other.
Liveblocks Yjs
is a CRDT-like data structure that allows these changes to be merged
automatically.
const yText = yDoc.getText("content"); // User A types at position 0yText.insert(0,"Hello"); // User B types at position 0 simultaneouslyyText.insert(0,"Hi "); // Both insertions preserved, merged deterministically// "Hi Hello"yText.toString();
The decision framework seems straightforward, but you might be thinking: “Can’t
I just build this myself?”. It’s harder than it looks, which is why platforms
exist to solve this. When I talk to clients who built their own sync engine,
they consistently underestimated the following components.
You can’t just use Date.now() for timestamps. System clocks drift, users
change their time zones, and servers can have clock skew. One approach is Hybrid
Logical Clocks.
typeHybridLogicalClock={ wallTime:number;// Physical time component counter:number;// Logical counter for same wallTime nodeId:string;// Unique node identifier for ties}; // When receiving a remote timestampfunctionreceiveTimestamp(localClock:HLC, remoteClock:HLC):HLC{const maxWallTime =Math.max(localClock.wallTime, remoteClock.wallTime);const physicalTime =Date.now(); if(maxWallTime >= physicalTime){// Logical time is ahead of physical - increment counterreturn{ wallTime: maxWallTime, counter: localClock.counter+1, nodeId: localClock.nodeId,};}else{// Physical time caught up - reset counterreturn{ wallTime: physicalTime, counter:0, nodeId: localClock.nodeId,};}}
Note that systems with centralized servers (like Linear and Figma) can sidestep
this complexity with server-assigned ordering, but truly distributed systems
need HLCs or similar mechanisms. The
original HLC paper details
why this matters for distributed systems.
You need a database schema that handles millions of events efficiently. Here’s
the difference:
-- Naive approach that doesn't scaleCREATETABLE changes ( id BIGSERIAL PRIMARYKEY, document_id UUID, attribute TEXT,value JSONB,timestampBIGINT); -- Production approachCREATETABLE changes ( id BIGSERIAL PRIMARYKEY, document_id UUID, client_id TEXT, client_sequence INTEGER,-- Client's local counter attribute TEXT,value JSONB, server_timestamp BIGINT,-- Server-assigned ordering deleted BOOLEAN, UNIQUE(document_id, client_id, client_sequence)); CREATEINDEX idx_changes_syncON changes(document_id, server_timestamp)WHERENOT deleted;
The production schema tracks both client-side causality (client_id +
client_sequence) and server-side ordering (server_timestamp). When conflicts
occur, server timestamp wins. The UNIQUE constraint prevents duplicate
operations from the same client.
This approach, used by Linear and Figma, is simpler than full vector clocks but
reliable because the server provides total ordering.
This complexity is exactly why Liveblocks exists—to handle the difficult sync
infrastructure so you focus on your application. Liveblocks is the only hosted
solution that offers both sync types, providing:
Liveblocks Storage for property-level sync (the Figma/Linear pattern)
Liveblocks Yjs for character-level sync (the Google Docs pattern)
import{ createClient }from"@liveblocks/client";import{LiveObject,LiveList,LiveMap}from"@liveblocks/client"; const client =createClient({ publicApiKey:"pk_prod_...",}); // Enter a room with typed storageconst{ room }= client.enterRoom("design-doc-123",{ initialStorage:{// Map of shape ID to shape object shapes:newLiveMap<string, LiveObject<Shape>>(),// Ordered list for layers layers:newLiveList<string>(),},}); // Subscribe to storage changesroom.subscribe(room.getStorage().root,()=>{// React to any storage changeconst shapes = room.getStorage().root.get("shapes");renderCanvas(shapes);}); // Make changes that sync automaticallyconst shapes = room.getStorage().root.get("shapes");const shape =newLiveObject({ type:"rectangle", x:100, y:100, width:200, height:100, fill:"#FF0000",}); shapes.set("rect-1", shape); // Later: update individual propertiesshape.set("x",150);// Only x syncs, not the whole object
The collaborative sync landscape has matured significantly. The fundamental
patterns, property-level LWW for objects, character-level CRDTs for text, are
well understood and battle-tested in production
(Figma,
Linear,
Yjs).
These are different problems requiring different solutions. Figma doesn't use
Yjs because property-level sync is the right model for design tools. Google Docs
doesn't use property-level sync because character-level CRDTs are required for
text editing.
Choose based on your primary data model:
If you're building...
Use...
Objects with properties (design tools, kanban boards)
The techniques presented here represent battle-tested approaches from production
systems serving millions of users. While there are
other sync engines and approaches
out there, the decision framework remains the same: match your data model to the
right conflict resolution strategy.
Now go build something collaborative. Reach out if you get stuck.