Search IOCombats

Search challenges, guides, questions and articles

How Figma Built Multiplayer Editing Without CRDTs or Operational Transforms
FigmaMultiplayer EditingCRDTSystem DesignReal-Time CollaborationFrontend Architecture

How Figma Built Multiplayer Editing Without CRDTs or Operational Transforms

By Ghazi Khan | Sep 7, 2026 - 12 min read

Open a Figma file with three other people in it, drag a rectangle, and everyone sees it move in the same frame. No merge conflicts, no "someone else is editing this" lock screen, no refresh needed. Figma shipped this in 2016, when no other design tool had it, and the engineering team later published exactly how they built it. That write-up, from Figma co-founder Evan Wallace, is one of the most detailed public accounts of a production multiplayer system, and it keeps showing up in system design interviews because it answers a question interviewers actually care about: what do you do when the textbook answer (CRDTs) is more machinery than your problem needs.

This post walks through that system in depth: the document model, the conflict resolution rule, how the client hides network latency without flickering, and the specific data structure problem (keeping a tree of objects consistent under concurrent reparenting) that turned out to be the hardest part. Every mechanism here traces back to Figma's own engineering blog, not speculation.

The problem: two people editing the same document at once

When two clients change the same piece of data at close to the same time, and updates travel over a network with unpredictable delay, you need a rule for what the final state should be, and every replica needs to agree on it without a human resolving the conflict by hand. There are two well-known families of solutions to this, and Figma looked hard at both before rejecting them.

Operational Transformation (OT) is the algorithm behind Google Docs. The idea: represent every edit as an operation (insert 3 characters at offset 12, delete 2 characters at offset 40), and when two operations arrive out of order, transform one against the other so both clients converge on the same result. If Alice inserts text at offset 5 while Bob deletes text at offset 3, Bob's delete shifts Alice's offset, so the server has to rewrite Alice's operation before applying it. This works, but the number of transform functions you need grows with the square of the number of operation types, because every operation type needs a defined transform against every other operation type. A text editor has maybe two or three operation types. Figma's document has dozens (position, fill color, corner radius, stroke, constraints, and more), which would make OT's combinatorial explosion unmanageable.

CRDTs (Conflict-free Replicated Data Types) solve a harder problem: keeping replicas consistent with no central server at all, the way distributed databases do. A CRDT guarantees three things: any replica can be updated independently without coordination, an algorithm built into the data type resolves any inconsistency automatically, and all replicas eventually converge to the same value. Two simple examples show how:

  • Grow-only set: elements only ever get added, never removed. Since adding the same element twice is a no-op, you can apply updates in any order and land on the same set.
  • Last-writer-wins (LWW) register: a single value tagged with a timestamp and a peer ID. Whoever has the latest timestamp wins; ties break on peer ID.

Figma studied these and made a key observation: most of a CRDT's complexity exists to handle a decentralized network, where there's no single authority to declare a winner. Figma isn't decentralized. Every client editing a document connects to the same server process for that document. So Figma built something CRDT-inspired but simpler: a system that behaves like an LWW register at the property level, without needing timestamps at all, because the server itself defines the order of events just by being the single point every update passes through.

ApproachCoordination modelComplexity driverFits Figma?
Operational TransformationCentral server, but operations must be transformed against each otherTransform functions scale with operation-type count squaredNo, too many property types
Pure CRDTFully decentralized, no central authorityExtra bookkeeping (vector clocks, tombstones) to guarantee convergence without a refereeNo, overhead solves a problem Figma doesn't have
Figma's property-level LWWCentral server per document, server order is the tiebreakerMinimal: last value to reach the server winsYes

The document model: a tree that's also a map

Every Figma file is a tree, structurally similar to the HTML DOM: one root, page nodes underneath it, and a hierarchy of shape and group nodes under each page. But instead of thinking about it as a tree first, it helps to think about it as a flat lookup table first: every object has an ID, and every object is a bag of properties and values.

// Conceptually, a Figma document is this shape:
type ObjectId = string;
type PropertyName = string;

type FigmaDocument = Map<ObjectId, Map<PropertyName, unknown>>;

// A rectangle node, simplified
const rectangleProperties = new Map<PropertyName, unknown>([
  ['type', 'RECTANGLE'],
  ['x', 120],
  ['y', 40],
  ['fill', '#3864DC'],
  ['parentId', 'page-1'],
]);

The tree structure (parent, page, children) is just more properties in this same map: a node's parentId is itself a value like any other. This single choice, modeling parent-child relationships as ordinary properties instead of a separate structure, is what makes the rest of the system work, because it means reparenting an object is handled by the exact same conflict resolution rule as changing its fill color.

Diagram
100%
flowchart TB root["Document root"] --> page["Page: 'Page 1'"] page --> frame["Frame node"] frame --> rect["Rectangle node<br/>x: 120, y: 40, fill: #3864DC"] frame --> text["Text node<br/>content: 'Submit'"] rect -. "stored as" .-> map["Map&lt;ObjectId, Map&lt;Property, Value&gt;&gt;"]
visualized byIOCombats

Conflict resolution: last write to the server wins, per property

Figma's server keeps track of the latest value any client has sent for a given property on a given object. Two clients changing different properties on the same object never conflict. Two clients changing the same property on different objects never conflict. A real conflict only happens when two clients change the same property on the same object at close to the same time, and when that happens, whichever change reaches the server last simply overwrites the other. No timestamp comparison is needed, because arrival order at the server is the timestamp.

Diagram
100%
sequenceDiagram participant A as Client A participant S as Server participant B as Client B A->>S: set(rect-1, fill, "#FF0000") B->>S: set(rect-1, fill, "#00FF00") Note over S: B's update arrives after A's S-->>A: broadcast fill = "#00FF00" S-->>B: broadcast fill = "#00FF00" Note over A,B: Both clients converge on green, no merge needed
visualized byIOCombats

This is why simultaneous text edits inside Figma don't merge character by character the way Google Docs does. If a text value is "B" and one person changes it to "AB" while another changes it to "BC" at the same moment, the result is either "AB" or "BC", never "ABC". Figma accepted this tradeoff deliberately: it's a design tool, not a prose editor, so whole-value conflicts on text are rare enough that losing character-level merging is worth the simplicity gained everywhere else.

Hiding latency without flickering

Every property change is applied on the client immediately, before the server acknowledges it, because waiting for a round trip on every keystroke or drag would make the tool feel laggy. This creates a subtle problem: if the client also applies every update broadcast from the server as it arrives, an older acknowledged value can briefly overwrite a newer local change that hasn't been acknowledged yet, causing a visible flicker back and forth.

Figma's fix: track which properties have unacknowledged local changes, and while a property is in that state, discard incoming server updates for it. Once the server acknowledges the client's own change, the property is no longer "pending" and future server updates apply normally again.

type PendingChange = { value: unknown; sentAt: number };

class OptimisticPropertyStore {
  private pending = new Map<string, PendingChange>();

  // Called when the user edits a property locally
  applyLocal(key: string, value: unknown) {
    this.pending.set(key, { value, sentAt: Date.now() });
    this.render(key, value);
    // ...send `value` to the server over the WebSocket
  }

  // Called when the server broadcasts someone else's (or our own) change
  applyRemote(key: string, value: unknown, isAckForUs: boolean) {
    if (this.pending.has(key) && !isAckForUs) {
      return; // discard: we have a newer unacknowledged local value
    }
    this.pending.delete(key);
    this.render(key, value);
  }

  private render(key: string, value: unknown) {
    // update in-memory scene graph / trigger a repaint
  }
}

This is the same principle behind optimistic updates in any modern frontend app (mutate local state immediately, reconcile with the server response later), applied at the granularity of a single object property instead of a whole API resource.

Creating and deleting objects

Object creation behaves like a last-writer-wins boolean: an object either exists or it doesn't, and that's just another property on it. Deletion is stricter than most systems: when an object is deleted, the server drops all of its property data entirely rather than soft-deleting it. That data survives only in the deleting client's local undo buffer, so if a document is edited and never undone, it doesn't accumulate a permanent history of dead objects and grow indefinitely.

New object IDs are generated on the client, not the server, because object creation has to work while offline. To guarantee two clients never generate the same ID independently, each client embeds its own unique client ID inside every object ID it creates, so collisions become structurally impossible without any coordination.

The hard part: keeping a tree a tree under concurrent edits

Storing a parentId as an ordinary property solves most conflicts for free, but it opens a new one: nothing stops two concurrent edits from creating a cycle. If Client A makes node A a child of node B, while Client B simultaneously makes node B a child of node A, both clients now think the other node is their parent, and the "tree" is no longer a tree.

The server can reject any parent update that would introduce a cycle, since it has full visibility into the whole graph at the moment each update arrives. But a client can't always tell in advance that its own pending change will be rejected, because it hasn't heard about the other client's conflicting change yet.

Diagram
100%
flowchart TD start["Client sends parentId update"] --> check{"Would this create<br/>a cycle in the tree?"} check -- "No" --> apply["Server applies update,<br/>broadcasts to all clients"] check -- "Yes" --> reject["Server rejects update"] reject --> orphan["Client detects a temporary cycle<br/>on its own copy"] orphan --> temp["Object temporarily removed<br/>from the visible tree"] temp --> resolve["Server's rejection arrives,<br/>client reparents to correct location"] resolve --> apply
visualized byIOCombats

Figma's answer is deliberately simple rather than clever: when a client detects that it's briefly in a cycle (because it applied its own unacknowledged change plus an incoming change that together form a loop), it temporarily removes the affected objects from the visible tree until the server's authoritative correction arrives. The object flickers out of the layers panel for a moment instead of rendering an invalid structure. It's a rare edge case, so a slightly ugly but simple fix beats a more complex one that's harder to verify.

Ordering children: fractional indexing

A tree also needs to know the order of siblings (which layer is above which). Figma solves this with fractional indexing: each object's position among its siblings is a number strictly between 0 and 1, and siblings are sorted by that number. To insert an object between two existing ones, you set its position to the average of their two positions, which always produces a value that sits exactly between them without touching any other sibling's position.

function positionBetween(before: number, after: number): number {
  return (before + after) / 2;
}

// Three siblings at positions 0.25, 0.5, 0.75
// Insert a new one between the first and second:
const newPosition = positionBetween(0.25, 0.5); // 0.375

The advantage over integer indices is that inserting or reordering one object never requires rewriting the position of every other sibling, which would itself be a source of conflicts under concurrent edits. The known limitation is floating-point precision: repeatedly inserting between the same two neighbors eventually runs out of representable values, which is why production implementations periodically rebalance positions in the background rather than relying on positionBetween forever. Figma stores the parent link and this position together as a single property, so a reparent and a reorder always commit atomically instead of racing each other.

Undo and redo across multiple people

Undo has an obvious meaning in single-player software: reverse your last action. In multiplayer, if someone else edited an object after you did, "reverse your last action" might silently overwrite their work. Figma's guiding rule: if you undo repeatedly, do something unrelated, then redo back to where you started, the document should look exactly as if you'd never undone at all. To make that hold, an undo operation rewrites the redo history at the moment it happens, and a redo operation rewrites the undo history at the moment it happens, rather than replaying a fixed, precomputed stack of inverse operations.

Practical takeaway

The transferable lesson isn't "use last-writer-wins for everything." It's the process Figma went through: identify the actual conflict granularity your domain produces (property changes on independent objects, in this case) before reaching for a general-purpose algorithm built for a harder problem you might not have. In a system design interview, naming CRDTs or OT for a "build a Google Docs" question is table stakes; being able to explain why a simpler, centralized, property-level LWW scheme is correct for a structured-data tool (and not correct for free-text editing) is what separates a memorized answer from one that shows real understanding. If you're prepping for interviews that touch collaborative editing, be ready to reason about three things specifically: what your conflict granularity is, whether you actually have a decentralized system or just a distributed one with a central authority, and how you'd order a growing list of siblings without rewriting the whole list on every insert.

Conclusion

Figma's multiplayer system works because it never tried to be a general-purpose CRDT implementation. It matched the conflict resolution strategy to what a design tool actually needs: independent objects, mostly independent properties, and a server that's always in a position to declare an order. Every hard part that remained (tree cycles, sibling ordering, undo semantics) got a solution scoped to that specific problem instead of a heavier general framework. That's the pattern worth carrying into your own systems, and into how you talk about them in an interview.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge