Design Figma
500 k design files open at once, some with 200 people: objects converge live, cursors stream, and 300 MB files open in seconds.
Last updated 2026-10-26. Difficulty: hard. Patterns: multiplayer, last-writer-wins, websockets, fractional-indexing, checkpoints. Reported at Figma, Adobe, Atlassian, Notion, Microsoft, Google.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of Figma · Adobe · Atlassian · Notion, run as that company’s round is reported to go.
The short answer
How to design Figma in a system design interview, in five steps:
- Scope it. Many people edit one file at once, local edits never wait, structure edits, live cursors and selections, brief offline, then reconnect, version history, comments, open very large files quickly, sharing and permissions.
- Set the targets. Local responsiveness: < 16 ms, next frame; Propagation to others: p95 < 200 ms same region; Convergence: every client = server state; Durability: no acknowledged change lost.
- Do the numbers. Open WebSocket connections: ~1 M; Change batches per second: ~500 k/s in · ~1 M/s out; Cursor updates per second: ~1.8 M/s.
- Draw the design. Comments service, Metadata DB, File API, Change journal, File router, Multiplayer gateway, Multiplayer server, Checkpoint store, CDN.
- Go deep where it is hard.
- Property-level last-writer-wins → Server-ordered last-writer-wins per (object, property).
- Reparenting, cycles and child order → Parent and fractional position on the child; the server rejects cycles.
- One owner per file, and when it dies → Router assigns files to hosts; host leases; journal fencing by conditional write.
- Checkpoints plus a journal → Page-chunked checkpoints plus an append-only journal.
- Opening a 300 MB file in seconds → Page-chunked checkpoints from the CDN, progressive render, lazy images.
- Presence and the 200-person file → Separate lossy presence relay with per-recipient batching and viewport filtering.
Functional requirements
- Many people edit one file at once. Shapes, frames, text, components and their instances. A change shows up for everyone in the file within a couple of hundred milliseconds, and every client ends up showing the same document.
- Local edits never wait. Creating, moving, resizing and restyling apply on the next frame. Even creating an object needs no round trip, which is why object ids are generated on the client.
- Structure edits. Group, ungroup, move a layer into another frame, reorder layers. Concurrent moves must never lose an object or make a frame its own ancestor.
- Live cursors and selections. Who is in the file, where their pointer is, what they have selected, and follow mode for a presenter. High frequency, lossy, and never saved.
- Brief offline, then reconnect. A dropped connection or twenty minutes on a train. Edits made meanwhile are kept and applied when the connection returns, without a conflict dialog.
- Version history. Autosaved and named versions; view any of them and restore one. A restore is a new edit that everyone in the file sees, not a rewrite of the past.
- Comments. Threads pinned to an object or a point on the canvas, with mentions. A pin follows its object when someone moves it.
- Open very large files quickly. Design systems and big product files reach hundreds of MB of document data plus images. The first page must be usable in seconds, not after the whole file downloads.
- Sharing and permissions. View, comment and edit roles per file, team and link. A viewer’s socket cannot send changes, and a revoke applies to a session that is already open.
- Out of scope. The rendering engine itself, prototyping and Dev Mode, plugins, publishing component libraries across files (instances inside one file are in scope), and billing.
Non-functional requirements
- Local responsiveness (< 16 ms, next frame). The server is never on the path of showing your own change. Everything about the conflict model follows from applying edits locally first.
- Propagation to others (p95 < 200 ms same region). Edits and cursors from collaborators. Cursors are sent at 10 Hz and interpolated on the receiver, so they look smooth without 60 Hz traffic.
- Convergence (every client = server state). Once changes stop flowing, every client shows exactly the server’s document. This drives the hardest trade-off: one server ordering each file makes property-level last-writer-wins enough and avoids a general CRDT, but that server is then a single owner whose failure must be survivable.
- Durability (no acknowledged change lost). An acknowledgement means the change is in the journal. Periodic checkpoints alone would lose everything since the last one when a server dies.
- Recovery from a server crash (file live again < 10 s). Clients keep editing locally meanwhile and resend what was not acknowledged. Bounded by the lease expiry plus loading the checkpoint and the journal after it.
- Open time (first page usable < 3 s). For any file size; typical files open in under 1.5 s. A 300 MB file cannot be downloaded whole in that time, which forces page-at-a-time loading.
- Scale (500 k open files · 1 M sockets). 10 M weekly users. Most files have 1 to 5 people in them; a design review can have 200, nearly all of them watching.
- Access control (server-side · revoke < 5 s). Design files are unreleased products. Roles are checked when the socket opens and enforced on every change, and a revoke ejects a live session.
Back-of-envelope estimates
- Open WebSocket connections: ~1 M. 500 k files with someone in them × an average of 2 clients each = 1 M sockets. At ~50 k per gateway host that is about 20 gateways. Connections are not the hard part.
- Change batches per second: ~500 k/s in · ~1 M/s out. About 20 % of open files have someone mid-edit at any instant: 100 k files. Each sends ~5 coalesced batches a second (a drag flushes at up to 20 Hz, then goes quiet) = 500 k batches/s, ~300 B each. With ~3 people in an active file, each batch goes to 2 others: ~1 M pushes/s.
- Cursor updates per second: ~1.8 M/s. About 600 k clients share a file with someone else (a solo file sends no presence at all). If 30 % are moving their pointer at once at 10 updates a second: 180 k × 10 = 1.8 M/s, 3.6 times the edit rate. This is why presence gets its own lossy channel.
- The 200-person design review: ~400 k msgs/s → ~4 k/s. 200 people each sending 10 cursor updates a second, each delivered to the 199 others as its own message: 200 × 10 × 199 ≈ 400 k messages/s from one file. Batching per recipient every 50 ms caps it at 200 × 20 = 4 k/s, each listing the cursors that moved (up to ~2 KB, ~40 KB/s per viewer). Batching, not more servers, is the fix.
- Multiplayer hosts (memory): ~100–150 hosts · 3–5 k files each. 500 k open files × 20 MB average in memory (median ~4 MB; the largest decode to 1–2 GB) = 10 TB. At ~100 GB of document memory per host: 100 hosts, about 5 k files each. Run ~150 so a host loss or a burst of big files has room, ~3 k files each. Memory, not CPU, sizes this tier.
- Journal writes: ~150 MB/s · ~13 TB/day. 500 k batches/s × 300 B = 150 MB/s, about 13 TB a day. Entries expire an hour after a checkpoint covers them, so the live journal is roughly the last hour of writes: ~550 GB across all files. A write-heavy, short-retention log, nothing like a document database.
- Checkpoint writes: ~170 MB/s. 100 k actively edited files, one checkpoint every 10 minutes each, writing only the pages that changed (~1 MB on average): 100 k × 1 MB ÷ 600 s ≈ 170 MB/s, ~15 TB a day. Rewriting whole files (5 MB mean) would be 5× that. Unpinned checkpoints expire after 7 days (~100 TB rolling); pinned versions stay.
- Downloading the largest file: ~48 s whole · ~1.6 s first page. A 300 MB checkpoint over a 50 Mbps connection: 300 × 8 ÷ 50 = 48 s. The first page’s chunk, capped near 10 MB, takes ~1.6 s. Downloading the whole file before showing anything is not an option at the bandwidth users have.
Components
- Comments service (pinned to object ids): Comment threads stored as rows next to the file metadata, each pinned to an object id plus an offset, or to a canvas point. The client places the pin from the object’s current position, so a pin follows its frame when someone moves it. Deliberately outside the multiplayer document: comments need permissions, mentions, notifications and queries across files, none of which the document model is good at.
- Metadata DB (sharded Postgres): Files, projects, teams, roles, share links and comments, sharded by file or team id. Relational and strongly consistent because a revoke must be exact and comment threads are ordinary rows. Nothing in it is on the per-change path: it is read when a file is opened and written when a comment is posted or a share changes.
- File API (open · auth · session token): The HTTP entry point for opening a file: checks the caller’s role in the metadata DB and returns a signed session token (file, user, role, 10-minute expiry) and the multiplayer address. The gateway validates that token without a database call. Also serves the file browser, sharing changes and other ordinary CRUD.
- Change journal (append-only · (file, seq)): Every accepted change batch, keyed by file id and sequence number, written before the change is acknowledged or broadcast. Appends are conditional writes on (file, seq), so a new owner writes a fence entry and any late append from an old owner collides and fails. Entries expire an hour after a checkpoint covers them: a high-write, short-retention log, a DynamoDB-style table or a BookKeeper-style log.
- File router (host leases · file → owner): Maps each open file to the host and worker that own it, with an epoch that increases on every move. Leases are held per host and renewed every second, not per file, so 500 k open files cost about 150 heartbeats a second. Places a new file by memory headroom using the size of its last checkpoint. etcd or ZooKeeper class: small, strongly consistent, and off the edit path.
- Editor client (local copy · pending queue): Holds the document (or the pages loaded so far) in memory and applies every edit locally on the next frame. Creates object ids as its session number plus a counter, so making a layer needs no round trip. Keeps unacknowledged changes in a pending queue and ignores server values for any property it has a pending change on, which is what stops remote edits from flickering over your own.
- Multiplayer gateway (WebSocket · fan-out): Terminates about 50 k WebSockets per host, validates the signed session token once at connect, and routes each file’s traffic to the worker that owns it. It also fans out: an owner sends each outbound batch once per gateway and the gateway copies it to its own sockets. Because browsers stay connected to the gateway, a multiplayer host failover is a re-route behind it, not a reconnect.
- Multiplayer server (one owner per file · in memory): The authority for each open file: holds the whole document in memory as a map from object id to properties, applies changes in arrival order with last-writer-wins per (object, property), and rejects changes that would break the tree. Journals each batch, then acknowledges and broadcasts it. Each file lives on exactly one single-threaded worker, about a hundred files per worker, so ordering is local and needs no locks; it also writes the file’s checkpoints.
- Checkpoint store (object storage · page chunks): The document at a sequence number: a small manifest plus one compressed binary chunk per page, and a shared chunk for components and styles used across pages, each named by its content hash. Only changed pages are rewritten, so a checkpoint of a 300 MB file usually writes a few MB, and versions share unchanged chunks for free. Immutable objects, so the CDN can cache them forever.
- CDN (immutable chunks · images): Serves checkpoint chunks and images from the edge with year-long cache headers, because both are named by content hash and never change. Access is through short-lived signed URLs, so a cached chunk is still unreadable without access to the file. Takes the bulk of every file open off the multiplayer servers.
- Presence relay (in memory · lossy · 50 ms): Cursors, selections, viewports and who is in the file, held in memory per file and never written anywhere. Batches each file’s updates every 50 ms into one message per gateway, drops stale updates instead of queueing them, and in files over 50 people sends full-rate cursors only for people near the viewer’s viewport. Placed by consistent hashing with no lease: if a relay dies, the next updates rebuild its state within a second.
- Event bus (Kafka · keyed by file id): Low-rate file events from the multiplayer servers: checkpoint written, editing went idle, file renamed. Never individual changes, which would be 500 k messages a second that no consumer needs. Keyed by file id so each file’s events stay in order for version history, thumbnails, search and notifications.
- Version history (pinned checkpoints · restore): Turns checkpoints into versions: pins the one taken when editing goes quiet for 30 minutes, plus every named version, and lets unpinned ones expire after 7 days. Viewing a version opens its manifest read-only from the CDN. Restoring asks the file’s owner to apply the difference as ordinary changes, so the restore is itself undoable and everyone in the file sees it arrive.
- Asset store (images by content hash): Image and video fills, uploaded by the client and named by the hash of their bytes, so the same screenshot pasted into a thousand files is stored once and a repeat upload is skipped. The document holds only the hash in a fill property. Several resolutions are stored per image, so a zoomed-out canvas fetches thumbnails, not originals.
User flows
- Open a large file. The main read path. Most of the bytes come from the CDN rather than the multiplayer server, and the first page is usable before the rest of the file has arrived.
- Client asks the File API to open the file; it checks the caller’s role and returns a session token. The role lookup happens once per open, not per change. The token carries file, user and role and expires in 10 minutes; the client refreshes it over the socket, which bounds how long a missed revoke could last.
- Client opens a WebSocket; the gateway looks up which worker owns the file. The answer is cached at the gateway and only fetched again when the epoch changes, so the router sees opens and moves, never changes. If nobody owns the file, the router places it on a host with memory to spare for the file’s size.
- If the file is not in memory, the owner loads its latest checkpoint and the journal entries after it. A checkpoint at, say, seq 41 200 plus at most about 10 minutes of journal. Inside the region the server pulls chunks in parallel at around 1 GB/s, so even a 300 MB file is in memory in a few seconds; most files take tens of milliseconds.
- The owner tells the client which checkpoint it is based on and gives this tab a session number. The client downloads that exact checkpoint and the server then sends everything after it, so nothing is missed or applied twice. The session number is unique within the file and prefixes every object id this tab creates (1874:1, 1874:2, …).
- Client downloads the manifest, the shared chunk and the current page’s chunk from the CDN, and renders. Chunks are named by content hash, so the CDN caches them forever and a second person opening the same file usually hits the edge. The shared chunk (components and styles used across pages) comes first, so instances on the first page render correctly. Other pages download in the background or when the user switches to them.
- Images are fetched lazily as they come into view, at the resolution the zoom level needs. Image bytes are usually most of a large file’s weight and none of its structure. Fetching only what is on screen, at thumbnail size when zoomed out, keeps the first render light; a placeholder with the image’s size and average colour shows until it lands.
- The owner streams the changes made since the checkpoint, and the client joins presence. The catch-up is at most about 10 minutes of edits, since checkpoints are taken every 10 minutes during activity. Changes to pages that are not loaded yet are buffered on the client and applied when that page arrives. From here on the client is live.
- Two people edit the same object. The core loop. Both edits apply locally first; the server orders them, the last one it receives wins for each property, and both clients end up showing the same thing without flicker.
- A designer drags a frame and changes its fill; the client applies both on the next frame. The change is a set of (object id, property, value) triples: x, y and fills on object 1874:12. Nothing waits for the network, and the triples go into the pending queue in the same instant.
- The client coalesces changes per frame and sends at most 20 batches a second. A drag changes x and y 60 times a second, but only the latest value per property matters, so a batch carries one value per (object, property). Each batch has the session number and a batch number, which is how the server recognises a resend after a reconnect.
- The owner checks the role and the tree rules, then applies each property in arrival order. Last-writer-wins per (object, property), where last means last to reach this single-threaded worker, so client clocks never matter. Meanwhile a colleague set the same frame’s fill to red: whichever batch arrived second wins the fill, and x and y are untouched by that conflict. Reparents that would create a cycle and changes to deleted objects are rejected here.
- The batch is appended to the change journal before anyone hears about it. Group commit: all batches for a file within a 10 ms window go in one conditional write, so a busy file costs tens of writes a second, not hundreds. The entry records (session, batch) so a resend can be recognised after a failover. Adds roughly 10–20 ms, well inside the 200 ms budget.
- The owner acknowledges the author and broadcasts the batch to everyone else through their gateways. The ack carries the sequence number, so the author drops those changes from its pending queue. Broadcast waits for the journal, so no one ever sees a change a crash could take back. One copy goes to each gateway with subscribers to the file, and the gateway copies it to its sockets.
- A client with a pending change to a property ignores the server’s value for it until its own is acknowledged. Without this rule, your frame would flash to your colleague’s colour and back while your own change is in flight. Once yours is acknowledged, whatever the server sends afterwards for that property is genuinely newer and is applied, so both clients still converge. Figma has described this rule in its public writing on multiplayer.
- A pasted image is uploaded through the CDN to the asset store, and only its hash enters the document. The bytes never pass through the multiplayer server or the journal: a 5 MB screenshot becomes one fill property holding a 32-byte hash. The image shows locally at once, and the client sends the change after the upload completes so no one fetches a hash that does not exist yet.
- A 200-person design review. Where per-file scale, not file count, breaks the naive design: one presenter, 199 watchers, and 400 k cursor messages a second if nothing is batched.
- Two hundred people open the file; their sockets are spread over about 20 gateways. All of them route to the one worker that owns the file. Most are viewers, whose tokens let them receive changes but not send any, which the owner enforces on every batch.
- The owner sends each outbound batch once per gateway, and each gateway copies it to its own sockets. One worker writing to 200 sockets would spend its time on syscalls and encryption. About 20 internal sends per batch, with the copying spread over the gateways, keeps the worker free to order changes for the other files it holds.
- Outbound changes are batched per 33 ms tick for files with many subscribers. When several people drag at once, a tick merges their batches into one message per gateway, keeping only the latest value per property. Files with a handful of people skip the tick and send immediately, so small files pay no latency for it.
- Every client throttles its cursor to 10 updates a second and sends it on the presence channel. Presence frames share the socket but not the path: the gateway hands them to the presence relay, never to the multiplayer server. Receivers interpolate between updates, so 10 Hz looks smooth.
- The presence relay merges the file’s cursors every 50 ms into one message per gateway. This turns 400 k messages a second into about 4 k: each viewer gets at most 20 presence messages a second listing the cursors that moved. Above 50 people the relay sends full-rate cursors only for people on the viewer’s page and near its viewport, and everyone else as a once-a-second avatar list.
- The presenter’s viewport is part of their presence, so followers’ screens track it. Follow mode needs the presenter’s page and viewport rectangle at the same 10 Hz. It is presence, so a dropped update is corrected by the next one; nothing about following touches the document.
- A viewer on a slow connection loses presence first, then gets a resync if changes back up. Each socket has two queues: presence keeps only the newest message, and changes queue up to a limit (2 MB or 5 seconds). Past that the gateway closes the socket and the client reloads from a checkpoint, instead of holding unbounded memory for one slow reader.
- A multiplayer server dies mid-session. What the single owner costs: when its host dies, every file on it must come back elsewhere from the last checkpoint plus the journal, with no acknowledged change lost and no batch applied twice.
- A host dies; gateways lose their internal connections, but browsers stay connected and keep editing locally. Users see a reconnecting badge, not an error. Their changes keep applying locally and pile up in the pending queue; nothing they have not seen acknowledged is treated as saved.
- The host’s lease expires and the router reassigns its files to other hosts with a higher epoch. Leases last 5 seconds and are renewed every second, so detection takes about 5 s. About 3 000 files spread over 149 hosts is roughly 20 each, so the reload is parallel rather than a queue. Placement accounts for size, so one host does not inherit three 2 GB files.
- The new owner fences the journal, then loads the latest checkpoint plus every entry after it. The fence is a conditional write of a marker at the next sequence number. The old owner appended strictly in order, so if it was only paused and wakes up, its next append lands on a slot that is already taken and fails. The new owner then holds every change the old one ever acknowledged.
- Gateways re-route the sockets; each client reports its last seen sequence and resends its unacknowledged batches. The client cannot know whether its last batches were journaled before the crash, so it resends all of them. The owner replies with the changes after the client’s last seen sequence, and the client re-applies its pending changes on top.
- The owner skips any resent batch the journal already holds, by session and batch number. Re-applying is not harmless under last-writer-wins: an old batch applied again after a colleague’s newer change would undo their work. The last applied batch per session is rebuilt from the journal during the load, so this check is a memory lookup.
- The same path serves a laptop that was offline for twenty minutes. If the gap is within the hour of journal kept after each checkpoint, the owner sends the missed changes. Otherwise the client downloads a fresh checkpoint, re-applies its pending changes on top and sends them. Figma has described reconnecting this way: fetch the latest state, reapply offline edits, resume syncing.
- Versions, restore and comments. Everything outside the live loop. Versions are pinned checkpoints, a restore is an ordinary edit, and comments are rows in a database; none of it can slow down a drag.
- Every 10 minutes of activity, the owner checkpoints the pages that changed and announces it on the bus. Encoding runs from a copy of the changed pages on a background thread, so the worker keeps applying edits. Unchanged pages keep their old chunk hash, so a checkpoint of a 300 MB file where one page changed writes a few MB. Also taken when the last editor leaves, so an idle file opens without a journal tail.
- Version history pins a checkpoint when editing goes quiet for 30 minutes, or when someone names a version. Pinning marks the manifest and its chunks to be kept; unpinned checkpoints expire after 7 days. Naming a version asks the owner for a checkpoint now, so the version is exactly what the user saw. Because chunks are shared by hash, a thousand versions of a mostly unchanged file cost little more than one.
- Opening an old version loads its manifest read-only from the CDN, with no multiplayer session. Old versions are immutable, so they need no owner, no journal and no ordering. The same chunk loader that opens a live file renders them, page by page.
- Restoring a version asks the owner to apply the difference as ordinary changes. The owner compares the old version with the live document per (object, property) and applies the difference as one batch: re-created objects keep their old ids, removed ones are deleted. It is journaled and broadcast like any edit, so everyone in the file sees it arrive, and restoring again undoes it. The past is never rewritten.
- A comment is posted to the comments service, pinned to an object id and an offset. An ordinary transactional insert into sharded Postgres, with the role checked in the same database. It never touches the multiplayer server, so a burst of comments in a review costs the live session nothing. Other people in the file get it through a subscription on the comments service.
- The client draws each pin from its object’s current position, so pins follow moved frames. The comment stores the object id and an offset, plus the absolute canvas point at the time of posting. If the object is later deleted, the pin falls back to that point instead of disappearing.
Deep dives
Property-level last-writer-wins
Google Docs needs OT or a CRDT to merge edits. Why can a design tool get away with last-writer-wins?
A text document is one long sequence, and concurrent inserts at the same spot are the normal case: two people typing in one paragraph must both keep every character, which is why Docs needs a sequence CRDT or OT. A design file has a different shape: a tree of objects, each a bag of properties (x, y, width, fills, text, parent). With 1 to 5 people in most files, two of them setting the same property of the same object within the same few hundred milliseconds is rare, and when it happens there is no meaningful merge of two colours or two x positions anyway.
The key observation is that a single server already orders every change to a file. With one authority, the conflict rule can be the simplest one that converges: for each (object id, property), the value the server received last wins. What remains hard is everything that is not a single property: creating objects without a round trip, moving objects between parents without cycles, ordering siblings, and stopping the local echo from flickering.
- Server-ordered last-writer-wins per (object, property) chosen
- A general CRDT for the whole tree (Automerge or Yjs style) situational: Offline-first or peer-to-peer products with no always-on server, or where long divergent offline sessions are the main use case.
- Operational transformation rejected
- Lock an object while someone edits it rejected
The answer: The server holds each file as a map from object id to a map of property to value, and applies changes in the order its single worker receives them: for each (object id, property), the latest value wins, and client clocks play no part. Object ids are generated on the client as session number plus a counter, so creating an object is just setting properties on an id nobody else can have, with no round trip. The client applies its own changes at once and ignores incoming values for any property where it has an unacknowledged change, so remote edits never flicker over yours; once yours is acknowledged, the server’s later values are applied, so everyone converges. Structure is properties too (a parent id and a position key), covered in the tree deep dive. Figma has written publicly (Evan Wallace’s 2019 post on its multiplayer technology) that it considered OT and chose a server-authoritative design inspired by CRDTs instead, because it has a central server anyway. The details here beyond that are my design.
Two people type in the same text layer at the same time. What happens?
The text is one property, so the server keeps whichever batch arrived last and the other person’s characters in that layer are lost. That is acceptable in a design tool: text layers are short, two people rarely type in the same one, and presence shows that someone else has it selected, which steers people away. If research said otherwise, the fix is local: make only the characters property a sequence CRDT, as in Google Docs, and keep last-writer-wins for everything else.
Someone edits offline for twenty minutes. Whose changes win?
Theirs, for any property they changed, because their batches reach the server last. That is the honest cost of last-writer-wins with no merge: a stale offline value can overwrite a colleague’s newer one on the same property. Changes to objects that someone deleted meanwhile are dropped, and the client tells the user. Version history makes it recoverable, and twenty minutes of offline edits rarely touch the same properties as a colleague’s.
Why can the client not just apply incoming server values immediately?
Because its own change is still in flight. You set the fill to blue, a colleague’s red reaches the server first and is broadcast to you, and your screen would flash red until your blue is acknowledged and comes back. Ignoring server values for properties with a pending local change removes the flicker, and it is safe: once the server acknowledges your change, any later value it sends is genuinely newer.
How do you test that every client converges?
Simulate it: a deterministic harness with one server and several clients, random edits on all of them, random delays, drops and reconnects, then assert that every client’s document equals the server’s once traffic stops. Run millions of seeds in CI and keep each failing seed as a regression test. In production, clients send a periodic hash of a few objects so the server can detect divergence and force a reload.
What does undo do in a multiplayer file?
It undoes only your own changes, by setting the properties you changed back to their earlier values, as a new edit. If a colleague has since changed the same property, I would skip it, so undo never destroys someone else’s later work. Undoing a delete re-creates the object with its original id and properties, which the client kept on its undo stack.
Reparenting, cycles and child order
Two people drag two frames into each other at the same moment. What happens, and how do you keep layers in order?
Most edits are a single property, but structure is not: moving a layer into a frame changes its parent and its place among new siblings, and two such moves can be valid one at a time and impossible together. Alice drags frame A into frame B while Bob drags B into A; applied blindly, A is inside B inside A and both vanish from the tree. Ordering is the other trap: if children are a list with indices, two people inserting at index 3, or one reordering while another deletes, shift each other’s positions.
The observation is to store structure on the child rather than on the parent. Each object has a parent property and a position property, and the position is a key that sorts between its neighbours rather than an index. A move is then two property changes on one object, ordinary last-writer-wins handles most of it, and the server needs one extra rule: refuse any parent change that would make an object its own ancestor.
- Parent and fractional position on the child; the server rejects cycles chosen
- Children as an ordered list on the parent rejected
- Move as delete plus re-create rejected
- A tree CRDT with move operations situational: Peer-to-peer or offline-first editing where no authority exists to reject a cycle.
The answer: Each object stores parent_id and a position key, and children are sorted by key. Keys are fractional indexes: strings that compare lexicographically, so inserting between "a0" and "a1" picks "a0V" without touching either neighbour, and a reorder is one property change. The client sends parent and position together in one batch so the server applies them atomically. Before applying a parent change, the server walks up from the new parent; if it meets the moving object, it rejects the change and sends the client the current values as a correction. A client that briefly creates a cycle locally (its own move plus a remote one) keeps the affected objects where they were until the answer arrives, tens of milliseconds later. Concurrent inserts can choose the same key; ties sort by object id so every client agrees, and the server rewrites keys that grow past about 50 characters as an ordinary change. Figma has written about using fractional indexing for child order and about its server rejecting parent changes that would create a cycle; the tie-break and rebalancing rules are my design.
Walk me through the A-into-B, B-into-A race exactly.
The server receives Alice’s batch first: A’s parent becomes B, which is valid, so it is applied and broadcast. Bob’s batch arrives next: B’s parent would become A, but walking up from A reaches B, so it is rejected and Bob gets B’s current parent and position as a correction. Bob’s client had both moves applied for a moment, saw the cycle and held both frames in place until the correction came. Everyone ends with A inside B.
Someone moves a layer into a frame that another person just deleted.
The server checks that the new parent exists. If the delete arrived first, the move is rejected and the mover’s client gets a correction putting the layer back under its old parent, where it still exists. If the move arrived first, the layer is now a child of the frame and is deleted with it when the delete lands. Either way the result is a valid tree in the server’s order, and undo can bring things back.
How long can fractional keys get, and what do you do about it?
Inserting repeatedly at the same spot grows a key by about one character per insert. Real files almost never pass a few characters, but a script inserting thousands of layers in one place could. The server watches key length and, past a threshold like 50 characters, reassigns evenly spaced keys to that parent’s children as a normal batch, which every client applies like any other change.
Why does the multiplayer server need to understand the tree at all? Could it just relay?
A relay cannot reject a cycle, check that a parent exists, or enforce that an instance still matches its component. Those checks need the whole document in memory, which is the main reason the owner holds the full file rather than passing changes through. It costs memory, about 10 TB across the fleet, and buys a document that is always a valid tree.
One owner per file, and when it dies
How do all 200 editors of a file end up talking to the same process, and what happens when that process dies?
Last-writer-wins only converges if there is one order, and the cheap way to have one order is one process: every change to a file goes to the single worker that holds it in memory, which applies, journals and broadcasts. That process now matters a great deal. A host holds 3 to 5 thousand files; when it dies, all of them stop until someone takes over, and if two processes ever believe they own the same file, they accept conflicting changes and write conflicting journals.
Two observations shape the answer. Ownership must be decided somewhere strongly consistent, but that place must stay off the per-change path, which at 500 k batches a second it could not survive. And recovery needs no special mechanism if the state is already durable: the checkpoint plus the journal is the file, so failover is loading it somewhere else, as long as the old owner cannot keep writing.
- Router assigns files to hosts; host leases; journal fencing by conditional write chosen
- Consistent hashing of file id to host, no registry rejected
- Stateless servers, every change written to a shared database rejected
- Hot standby replica per file situational: For a few critical, very large files where a multi-second reload is unacceptable, such as a company’s main design system during a launch.
The answer: A file router (etcd or ZooKeeper class) holds file → (host, worker, epoch) and a lease per host of 5 seconds, renewed every second. On connect the gateway asks the router once and caches the answer until the epoch changes; if nobody owns the file, the router places it by memory headroom, using the size of its last checkpoint. Inside a host, each file belongs to one single-threaded worker, about a hundred files per worker, so its changes are applied in order with no locks. When a host’s lease expires, the router reassigns its files with epoch + 1; each new owner writes a fence entry into the journal at the next sequence number, loads the latest checkpoint and the journal after it, and starts serving. Clients, still connected to their gateways, report their last seen sequence and resend unacknowledged batches, which the owner deduplicates by session and batch number. Users see a few seconds of reconnecting and lose nothing they saw acknowledged.
The old owner was not dead, just stuck in a 30-second pause. It wakes up and carries on.
Its next journal append is a conditional write at a sequence number the new owner’s fence already occupies, so it fails, and the old process learns it no longer owns the file and drops it. It may still try to broadcast, but gateways route by the router’s epoch and discard messages from an owner with an older one. Nothing it does after the fence reaches storage or clients.
A host with 3 000 files dies at 2 pm on a Tuesday. What load does recovery put on the system?
About 20 files to reload on each of 149 hosts, mostly small: at a 5 MB average that is 15 GB of checkpoint reads from object storage, a few seconds of parallel reads. Journal reads are small, at most an hour of changes per file. Clients do not reconnect, because their sockets are on the gateways, so there is no reconnect storm, only a burst of resume messages.
How do you deploy a new multiplayer server version without dropping everyone?
Drain hosts one at a time: for each file, checkpoint it, bump the epoch and let an upgraded host load it, a pause of tens of milliseconds for small files. Gateways re-route without the browser noticing. Moving a few hundred files a second drains a host in a minute or so and the fleet in a few hours, which is also as fast as you would want to roll out code that owns every open document.
Why are comments and permissions not part of the multiplayer document?
Because they need what the document is bad at and nothing it is good at. Comments are queried across files (my mentions, unresolved threads), trigger notifications, and are written by commenters who cannot edit the design; permissions must be checked before the document is even loaded. As rows in Postgres behind ordinary services, they never compete with drags for the owner’s CPU, and a comments outage leaves editing untouched.
A permission is revoked while the person is editing. How quickly does it take effect?
The sharing change publishes an event; the file’s owner, found through the router, downgrades or closes that session within a couple of seconds and rejects its batches from then on. As a backstop, session tokens expire every 10 minutes and are refreshed against the metadata DB, so even a lost event is bounded. Batches accepted before the revoke stay, since they were legitimate when made.
Checkpoints plus a journal
Why not just save the whole file to storage every 30 seconds?
Saving the in-memory document periodically is where most multiplayer servers start, and it has two problems. A crash loses everything since the last save, up to 30 seconds of work for every file on the host, which breaks the promise that an acknowledged change is kept. And it is expensive at the wrong end: a 300 MB file rewritten every 30 seconds while one person nudges a button is 10 MB/s for one file, and across 100 k active files the bandwidth and the bill explode.
The fix separates two jobs. Durability needs something cheap per change: an append-only journal of small batches, written before the acknowledgement. Fast loading and history need a compact image of the whole document: a checkpoint, taken occasionally and only of what changed. The file at any moment is the latest checkpoint plus the journal after it.
- Page-chunked checkpoints plus an append-only journal chosen
- Whole-file save every 30 seconds rejected
- Every object as rows in a database rejected
- Keep the full journal forever (event sourcing) situational: Where a fine-grained audit of every change is a product or compliance requirement; otherwise expire journal entries and keep pinned checkpoints.
The answer: Every accepted batch goes into a journal keyed by (file id, sequence number) before it is acknowledged, with per-file group commit every 10 ms; a DynamoDB-style table with cheap conditional writes fits, at about 150 MB/s fleet-wide. Every 10 minutes while a file is being edited, and when its last editor leaves, the owner writes a checkpoint: a manifest at that sequence number listing one content-addressed chunk per page plus a shared chunk for components and styles, in a compact binary encoding. Only changed pages produce new chunks, so checkpoints average about 1 MB and the fleet writes about 170 MB/s. Journal entries expire an hour after a checkpoint covers them, which is the window in which a reconnecting client can catch up from the journal. Version history is pinned checkpoints, so the feature costs only the changed chunks. Figma has written publicly about adding a journal alongside its periodic checkpoints so that a server crash no longer loses recent edits; the intervals, the chunking and the store choices here are my own.
How do you checkpoint a 1 GB in-memory document without freezing the editors?
Copy the changed pages’ objects into an immutable snapshot on the worker, which is quick because only changed pages are copied, then encode, compress and upload from a background thread while the worker keeps applying changes. The manifest records the sequence number of the snapshot, so changes applied during the upload are simply after the checkpoint and stay in the journal. If the upload fails nothing is lost; the next attempt includes more.
The journal store is slow for a minute. What do editors see?
Acknowledgements and broadcasts wait, because both follow the journal write, so collaborators see each other’s edits late while their own still apply locally. Pending queues grow on the clients, which is bounded and fine for a minute. If it lasts longer, the owner refuses new opens on that host; it does not acknowledge from memory, since that would break the one promise the journal exists to keep.
Why broadcast only after the journal write? That costs latency.
About 10 to 20 ms, inside a 200 ms budget. In exchange, nothing any client has seen can be taken back by a crash, which keeps recovery simple: the new owner’s state includes everything ever shown. Broadcasting first would save those milliseconds, but a crash could then show a change to colleagues and make it vanish from the file until the author’s client resends it.
What is in a checkpoint chunk, and why binary?
For each object on the page: its id, its type and its properties, with property names as small integer tags and values in a compact schema-based encoding, then compressed. JSON would be several times larger and much slower to parse, and parsing is a large part of opening a big file. The schema is versioned, so old checkpoints stay readable after new properties are added.
A bad deploy corrupts some files. How do you recover?
From the history the design already keeps: take the last checkpoint written before the deploy, apply the journal entries from before the bad version started writing, and apply the result as a restore, which is an ordinary edit. That works for the last hour from the journal and for anything older from pinned checkpoints. It also argues for making the checkpoint writer refuse to write a document that fails validation.
Opening a 300 MB file in seconds
Your biggest customers have 300 MB files, and downloading one takes 48 seconds. How does it open in under 3?
File size has a long tail. The median file is about 1 MB of document data and opens in a few hundred milliseconds, but design systems and big product files reach hundreds of MB, plus far more in images. At 50 Mbps, 300 MB takes 48 seconds to download, then seconds more to decode and render, and those are the files opened most often, by the most people.
The observation is that nobody looks at the whole file at once. A designer opens one page, at one zoom level, looking at a few hundred objects out of millions. If the stored form matches how the file is viewed (pages as separately fetchable chunks, images stored separately at several resolutions), the first screen needs a small fraction of the bytes.
- Page-chunked checkpoints from the CDN, progressive render, lazy images chosen
- Download the whole file before rendering rejected
- Stream the document from the multiplayer server rejected
- Server-rendered tiles for the first view situational: View-only embeds, link previews and very weak devices, where a picture of the canvas is enough.
The answer: The checkpoint is stored as a manifest plus one chunk per page and a shared chunk holding components, styles and anything referenced across pages; pages larger than about 10 MB are split by top-level frame. On open, the owner tells the client which manifest it is based on; the client fetches the manifest, the shared chunk and the current page’s chunk from the CDN, renders, then streams in other pages in the background. Image fills are fetched only when they are in or near the viewport, at a size chosen by the zoom, with the image’s dimensions and average colour as a placeholder. Changes for pages not yet loaded wait in a small buffer and are applied when the page arrives. Most opens are served from the edge, because chunk names never change. Figma has described loading the page a user opens first and the rest on demand; how it does that internally goes beyond what it has published, so the chunking here is my design.
An instance on page 1 uses a component defined on page 9. Does the client need page 9?
Not all of it. When the checkpoint is written, every component, style and variable referenced from a page other than its own goes into the shared chunk, which loads before any page. The cost is that the shared chunk can be large in a design-system file; it is cached aggressively, because it changes only when those components do.
If the client loads one page, what does the multiplayer server hold?
The whole file, still. It validates structure (cycles, parents that exist, instance rules) across every page, and any client may navigate anywhere. That is why server memory, about 10 TB across the fleet, is the sizing number and why placement looks at file size. If memory became the constraint, the server could keep cold pages in their compact encoded form and decode a page only when a change touches it.
200 people open the same file at 10 am for a review. What happens to the origin?
The first request for each chunk in each CDN region goes to the checkpoint store, and everything after is an edge hit, because chunks are immutable. Request coalescing at the edge stops 200 simultaneous misses in one region from becoming 200 origin reads. The multiplayer server sends each person only the catch-up since the checkpoint, kilobytes to a few MB.
How do you know opens are fast enough, and where the time goes?
Measure on the client, per open: time to manifest, to first page chunk, decode time, first render and time to interactive, by file-size bucket and region. Large files are few but opened often, so report percentiles weighted by opens, not by files. Most regressions show up as decode time from a new property, or a page that stopped being split.
Presence and the 200-person file
200 people join one file for a design review. What breaks first, and what do you do about it?
Per-file scale is the one thing sharding by file does not spread. In a 200-person file, cursor updates at 10 a second each would be 400 k messages a second if every update went to every other person as its own message, all from a single file. Edits are smaller in volume but go through the one worker that owns the file, and every one is copied 199 times.
The two kinds of traffic want opposite treatment. Edits must arrive, in order, exactly once. Cursors and selections are only worth their latest value; an update that is 200 ms old is worthless and should be dropped, not delivered late. Keeping them on separate paths lets each be built for what it is, and keeps a cursor storm from delaying a single edit.
- Separate lossy presence relay with per-recipient batching and viewport filtering chosen
- Presence through the multiplayer server, on the ordered stream rejected
- Peer-to-peer cursors over WebRTC rejected
- Fan-out at the gateway chosen
The answer: Presence (cursor, selection, current page and, for presenters, the viewport) shares the socket but is routed by the gateway to a presence relay, placed by consistent hashing on the file id, with no lease and nothing persisted. Clients send at most 10 updates a second and only when something changed; receivers interpolate. The relay merges each file’s updates every 50 ms into one message per gateway, and above 50 people sends full-rate cursors only for people on the viewer’s page and near its viewport, with everyone else as a once-a-second avatar list. Edits are batched per 33 ms tick for files with many subscribers and sent once per gateway, which copies them to its sockets. Each socket has two queues: presence keeps only the newest message, and changes are capped at a few MB, past which the gateway closes the socket and the client reloads from a checkpoint. With this, the review costs about 4 k presence messages a second, and the owner’s work grows with the number of gateways rather than the number of people.
Now it is 2 000 people watching a keynote file. What changes?
Treat most of them as an audience. Beyond a few hundred, viewers stop sending cursors, see an avatar count and the presenter’s viewport, and receive edits on a slower tick, say 100 ms. Edits still flow through one owner, which is fine because only a handful of people are editing; if 2 000 sockets span every gateway, a relay tier between owner and gateways keeps the owner’s sends constant.
A presence relay dies. What does a user see?
Cursors freeze for up to a second. Gateways notice the broken connection, rehash the file to another relay, and the next update from each client, within 100 ms, repopulates it. Clients re-send their selection when they reconnect to presence. No document state is involved, so there is nothing to recover and nothing to fence.
Why not send cursors at 60 Hz for smoothness?
Six times the traffic for something the eye cannot tell apart from interpolated 10 Hz. Receivers draw remote cursors a frame or two behind and interpolate between the last two positions, which looks smooth and costs nothing on the network. In large files the rate drops further, which is fine because nobody watches 199 cursors at once.
How would you measure whether multiplayer feels good?
End to end, on clients: the time from a change leaving one client to it rendering on another, at p50, p95 and p99, by region and file-size bucket, and the same for cursors. Add the rate of resyncs (sockets closed for backlog), rejected batches and corrections. Server CPU per worker matters for capacity, but alerts should fire on the client-measured latency.
The theory behind it
- CRDTs vs operational transformation: How collaborative editors merge concurrent edits: the problem of concurrent operations, operational transformation with a central server, CRDTs for text and data, convergence and intent preservation, metadata and performance costs, presence and cursors, and which to choose.
- Stateful vs stateless services: Why stateless services scale and recover easily, where state has to live instead (databases, caches, sessions, object storage), and how to run genuinely stateful services such as WebSocket gateways, game servers, caches and stream processors with sticky routing, partitioning, replication and draining.
- Presence and managing millions of connections: Holding millions of persistent WebSocket connections and showing who is online: connection gateways, routing messages to the right server, heartbeats, presence with TTLs, fan-out of status changes, typing indicators, reconnect storms and deploys.