Real-Time & Collaborative Editing
2026-08-09 · 17 min read
Real-time features split into two distinct problems. The first is live updates — keeping one user's view fresh as the server's state changes. The second is collaborative editing — merging concurrent writes from multiple users into a consistent document. They share transport mechanisms but diverge sharply in complexity.
Live updates
Five mechanisms exist for pushing data from server to client. They differ in latency, server cost, and directionality.
| Mechanism | Latency | Server cost | When to use |
|---|---|---|---|
| Short polling | 1–5s lag | High (idle requests) | Low-priority freshness checks |
| Long polling | Near-real-time | Medium | Simple push, no WebSocket support |
| SSE | Near-real-time | Low | Server-to-client only |
| WebSockets | Real-time | Medium | Bidirectional; feed reactions, chat, cursors |
| WebRTC | Peer-to-peer | Low (after signaling) | Audio/video, low-latency data channels |
Short polling — the client requests on a timer. Simple to implement, but every request is a potential no-op and the lag is bounded by the interval. Appropriate for low-priority updates (a dashboard that refreshes every 30 seconds) where the server cost of frequent connections isn't justified.
Long polling — the client requests and the server holds the connection open until there's data to return, then responds. The client immediately re-requests. Near-real-time, but each response requires a new HTTP round-trip. Effectively deprecated where SSE or WebSockets are available.
SSE (Server-Sent Events) — a persistent HTTP connection over which the server pushes newline-delimited events. Unidirectional by spec (server → client only), but that's sufficient for most feed-style updates. The browser handles reconnection automatically; the Last-Event-ID header lets the server resume from where it left off after a disconnect.
const source = new EventSource('/api/feed');
source.onmessage = ({ data }) => updateFeed(JSON.parse(data));
source.onerror = () => { /* browser auto-reconnects */ };
SSE uses standard HTTP, goes through proxies and load balancers without special configuration, and has lower overhead than WebSockets for unidirectional streams.
WebSockets — a persistent, full-duplex TCP connection. The client and server can both send at any time after the initial HTTP upgrade handshake. At scale (Facebook, Twitter), one socket multiplexes multiple event types: new post notifications, reactions, typing indicators, and presence updates all share a single connection. The client sends (typing indicators, reactions) and the server pushes (new content, others' actions) on the same channel.
WebRTC — peer-to-peer connections negotiated through a signaling server. The signaling server's job is to exchange two things between peers: an SDP offer/answer (each peer describes its media and data capabilities) and ICE candidates (the set of IP/port pairs through which each peer can be reached — directly, via STUN if behind NAT, or via a TURN relay as a fallback). Once that negotiation completes, data flows directly between clients and the signaling server is no longer involved. The dominant use case is audio and video (the server cost of relaying media at scale is prohibitive at scale), but WebRTC data channels also support low-latency binary and text. Figma uses WebRTC data channels for cursor positions between peers.
Choosing for common products
Feeds (social, news) — SSE is sufficient if the feed is read-only. WebSockets are the default at high scale because they let the client also send (reactions, comments) without opening a separate HTTP connection for each action.
Chat — WebSockets. The round-trip for message delivery and the bidirectional nature of typing indicators make anything else a poor fit. SSE would require a separate POST endpoint for sending messages, which works but adds complexity.
Live cursors and presence — WebSockets. Cursor positions are sent at high frequency from client to server to other clients; SSE can't carry the outbound leg.
Collaborative editing
Live updates assume only the server writes — clients are readers. Collaborative editing breaks that assumption: multiple clients write concurrently to the same document. Without coordination, writes conflict.
Two strategies dominate.
Operational Transform (OT)
The core problem OT solves: text positions are fragile. If Alice is at revision 5 of a document and types "!" at position 10, that operation is only correct relative to revision 5. If Bob simultaneously deleted characters 3–7, the document has shifted — Alice's "insert at 10" now points to the wrong place.
OT's answer: don't discard Alice's operation, transform it. Adjust the position to account for what Bob did, then apply the adjusted operation.
The transform function
For text, there are two operation types — insert(pos, text) and delete(pos, length) — and four transform cases:
transform(op1, op2) — adjust op1 assuming op2 already happened
insert vs insert:
op1 = insert("X", at 5)
op2 = insert("Y", at 3) ← op2 inserted before op1's position
result: insert("X", at 6) ← shift op1 right by op2's length
insert vs delete:
op1 = insert("X", at 5)
op2 = delete(3, 2) ← op2 deleted 2 chars before op1's position
result: insert("X", at 3) ← shift op1 left by deleted length
delete vs insert:
op1 = delete(5, 1)
op2 = insert("Y", at 3) ← op2 inserted before op1's position
result: delete(6, 1) ← shift op1 right
delete vs delete:
op1 = delete(5, 3) ← deletes positions 5–7
op2 = delete(4, 2) ← deletes positions 4–5, overlapping
result: delete(4, 2) ← shrink and shift op1 to account for overlap
The delete vs delete case is where OT gets hairy — overlapping deletions require calculating the intersection, and edge cases multiply quickly.
The server's role
Every client and the server share a revision number. When Alice sends an operation, she includes the revision she was on when she made the edit. The server receives operations from all clients, serializes them into a single authoritative order, and transforms each incoming operation against any operations that were applied since the sender's revision.
Server state: "Hello world", revision 5
Alice sends: insert("!", at 11), based on revision 5
Bob sends: delete(6, 5), based on revision 5 ← arrives first
Server applies Bob's delete first (it arrived first):
"Hello world" → "Hello ", revision 6
Alice's op arrives — it was based on revision 5, server is now at revision 6.
Server transforms Alice's op against Bob's delete:
insert("!", at 11) → insert("!", at 6)
Server applies transformed op:
"Hello " → "Hello !", revision 7
Server broadcasts to all clients:
→ Bob receives: insert("!", at 6) based on revision 6
→ Alice receives: ack, she's now on revision 7
Alice and Bob both converge to "Hello !" regardless of network ordering.
The one-in-flight rule
To keep the client-side transform logic tractable, OT systems enforce that only one unacknowledged operation can be in flight at a time. While waiting for an ack, the client buffers new local edits rather than sending them immediately.
Client state:
sentOp: insert("!", at 11) ← waiting for ack
pendingOp: insert("?", at 12) ← typed while waiting, not sent yet
Server acks sentOp, now at revision 7.
Client sends pendingOp — but first transforms it against any server ops
that arrived since revision 5 (when sentOp was sent).
Optimistic local application
The client doesn't wait for the server ack before showing the result. It applies its own operation to the local document immediately — the editor feels instant. The op is sent to the server at the same time.
While the op is in flight, server ops from other users can still arrive. The client must transform those incoming ops against its pending local op before applying them locally, so its own unacked edit stays in the right place.
Alice types "!" at position 11 — "Hello world" → "Hello world!" (local, instant)
Alice sends insert("!", at 11) to server [rev 5]
While waiting for ack, server sends: delete(6, 5) from Bob [rev 6]
Alice must transform Bob's delete against her pending insert:
delete(6, 5) is unaffected (Bob deleted before position 11)
Alice applies Bob's delete to her local doc: "Hello !" ✓
Server acks Alice's op at rev 7 — local state confirmed.
Without optimistic application, every keystroke would have visible latency equal to the round-trip to the server. The tradeoff is that the client must maintain the pending op and transform all incoming server ops against it until the ack arrives.
Why this matters: TP1 vs TP2
OT has two correctness properties. TP1 (pairwise convergence): if any two concurrent operations are applied in either order, they produce the same result. The four transform cases above satisfy TP1 for two operations, and this is tractable.
TP2 (three-way convergence): if three or more operations are concurrent, transforming any of them against any ordering of the others must still produce the same result. This is where OT becomes nearly impossible to implement correctly without a server. Decentralized OT (peer-to-peer) requires TP2, and most published peer-to-peer OT algorithms have had bugs in their TP2 proofs.
Three concurrent ops from Alice, Bob, Carol:
State S0
/ | \
OpA OpB OpC
↘ ↓ ↙
Must all converge
Every ordering — A→B→C, A→C→B, B→A→C, B→C→A, C→A→B, C→B→A —
must produce the same final state. The number of cases to handle
grows exponentially with the number of concurrent operations.
The server eliminates TP2 entirely. Because the server serializes all operations into a single total order, clients never see three concurrent ops from different peers simultaneously. Each client only ever needs to transform its one in-flight op against server ops — a TP1 problem. This is why the one-in-flight rule exists: it's not just about simplicity, it's about keeping the client in the TP1 regime where correctness is provable.
N² transform functions
A practical consequence of OT's design: for N operation types, you need N² transform functions. Insert and delete give you 4 (the cases above). Add bold, italic, and a heading format operation and you need 25 functions — one for every pair. Each new operation type multiplies the transform surface. This is why OT editors are typically built on a small fixed operation set, and why adding new formatting capabilities to an OT-based editor is expensive.
CRDTs (Conflict-free Replicated Data Types)
CRDTs take a different approach: instead of transforming operations, design the data structure so that merging is always unambiguous. The key insight for text is to abandon positions entirely — positions shift as edits happen, so they make a bad identifier. Instead, give every character an immutable unique ID that encodes where it was inserted relative to its neighbors. Positions become a derived view, not the source of truth.
Why positions fail
"Insert at position 5" means something different depending on what other edits have happened. OT patches this by transforming positions after the fact. CRDTs eliminate the problem by never using positions as identifiers in the first place.
The YATA model (what Yjs uses)
Yjs implements the YATA algorithm. Each character stores:
id:(clientId, clock)— globally unique, never changesoriginLeft: theidof the character immediately to its left when it was insertedoriginRight: theidof the character immediately to its right when it was inserteddeleted: whether it's a tombstone
Initial: "AB"
A { id: (alice, 1), originLeft: START, originRight: (alice, 2) }
B { id: (alice, 2), originLeft: (alice, 1), originRight: END }
Alice inserts X between A and B:
X { id: (alice, 3), originLeft: (alice, 1), originRight: (alice, 2) }
Bob inserts Y between A and B concurrently:
Y { id: (bob, 1), originLeft: (alice, 1), originRight: (alice, 2) }
Both X and Y have the same originLeft — they're concurrent inserts at the same position. Resolution: compare client IDs. "alice" < "bob" alphabetically, so Alice's character comes first. Both peers apply this rule independently and arrive at the same ordering: A X Y B.
No server involved. The deterministic tiebreak is baked into the data structure.
The interleaving problem
Earlier text CRDT algorithms (Logoot, LSEQ) used fractional position IDs — a character inserted between positions 0.3 and 0.4 might get ID 0.35. This approach suffers from interleaving: concurrent insertions from two users typing at the same spot can scramble their text character-by-character.
Alice types "car" concurrently with Bob typing "dog" at the same position.
Fractional ID approach might produce: "cdaorg" ← interleaved, unreadable
YATA/Yjs produces: "cardog" ← grouped, correct
YATA avoids interleaving because originLeft/originRight references keep each user's characters causally anchored to what they were actually adjacent to when typed. Consecutive characters from the same client form a causal chain; they can't be split by another client's concurrent inserts.
Deletions and tombstones
Deleting a character marks it as a tombstone — it stays in the structure with deleted: true but renders as invisible. The ID must remain because other peers might reference it as an originLeft or originRight.
Alice deletes Y (id: bob:1):
Y { id: (bob, 1), originLeft: (alice, 1), deleted: true }
Renders as: "AXB" — tombstone is invisible but still anchors future inserts.
Tombstones accumulate forever. A heavily-edited document can accumulate tombstones at 10–50× the live character count. Figma has documented documents with over 10 million tombstone entries from shape deletions. Yjs mitigates this by truncating deleted content to a length-only marker (keeping the ID but dropping the actual text), but the IDs themselves are never removed — removing them would risk divergence for any peer that references them as an origin.
Undo/redo makes this worse. Each undo marks a character as deleted (new tombstone); each redo revives it. Heavy undo/redo usage amplifies tombstone growth well beyond what normal editing produces.
Metadata overhead
Raw text costs 1 byte per character. A CRDT character costs 10–20 bytes: the ID (clientId + clock), originLeft, originRight, deleted flag. Yjs mitigates this with block grouping — consecutive characters typed by the same client are stored as a single block with one shared metadata header, amortizing the overhead for sequential typing. But random insertions from multiple users across a document still pay the full per-character cost.
Offline merging
The payoff for this complexity is offline support. A client can type 500 characters while offline, generating 500 CRDT operations with locally-assigned IDs. When it reconnects, it sends all 500 to the server. The server merges them with whatever other edits happened during the offline period — purely by applying the same deterministic tiebreak rules. No server was needed to serialize the operations as they were generated.
OT can't do this. Each OT operation is only valid relative to a specific server revision. A client offline since revision 5, when the server is now at revision 200, requires the server to transform every queued operation through all 195 intermediate revisions — which is effectively reprocessing the entire edit history.
OT vs. CRDT
| OT | CRDT | |
|---|---|---|
| Server required for convergence | Yes | No |
| Offline support | Poor | Good |
| Correctness requirement | TP1 (server handles TP2) | Commutativity + deterministic tiebreak |
| Transform functions | N² per op type pair | None |
| Metadata per character | Low (positions only) | 10–20 bytes (IDs, origins, tombstone) |
| Interleaving risk | None | Depends on algorithm (Yjs: none; Logoot/LSEQ: yes) |
| Tombstone growth | None | Unbounded (undo/redo amplifies) |
| Used by | Google Docs | Figma, Notion, Gitpod, Zed |
The practical decision is offline requirements. Always-connected apps with a server as the source of truth lean toward OT — the server eliminates TP2, the transform surface is fixed, and there's no tombstone overhead. Apps that need to work offline, support peer-to-peer sync, or want to avoid a central authority lean toward CRDTs — the data structure handles convergence without a server, at the cost of metadata overhead and tombstone management.
Reconnection and state recovery
WebSocket connections drop. Both OT and CRDT handle reconnection, but differently.
OT — the client tracks its last acknowledged revision. On reconnect, it sends { since: lastRevision } to the server. The server replays every op since that revision. The client transforms its queued pending ops against the replayed ops and reapplies. If the client was at revision 47 when the connection dropped and the server is now at revision 61, it receives 14 ops to catch up.
CRDT — the client tracks a state vector: a map of { clientId: highestClockSeen } for every peer it's aware of. On reconnect, it sends its state vector. The server compares it against its own and responds with only the ops the client is missing — those where a peer's clock in the server's record is higher than in the client's. No transformation needed; CRDT ops commute, so the client just applies the missing ops in any order.
Both should reconnect with exponential backoff and jitter: start at 1s, double on each failure (2s, 4s, 8s...) up to a cap, with random jitter added to each interval. Without jitter, all clients that lost connection at the same moment reconnect simultaneously and overwhelm the server.
Presence and live cursors
Cursor positions and user presence (who's online, where they're looking) are ephemeral — they don't belong in the document's operation log. A cursor position that's 200ms stale is useless; storing cursor history is wasteful.
Presence state is broadcast directly over WebSocket, outside the OT/CRDT pipeline:
// Throttle cursor sends — 50ms is ~20fps, sufficient for perceived smoothness
function throttle(fn, ms) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= ms) { last = now; fn(...args); }
};
}
const sendCursor = throttle((anchor, focus) => {
socket.send(JSON.stringify({ type: 'CURSOR_UPDATE', userId: currentUser.id, anchor, focus }));
}, 50);
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
sendCursor(selection.anchor, selection.focus);
}
});
});
// Server fans out to all other clients in the document session
socket.on('CURSOR_UPDATE', (data) => {
if (data.userId !== currentUser.id) {
renderRemoteCursor(data.userId, data.anchor, data.focus);
}
});
Without throttling, a selection listener fires on every keystroke — hundreds of times per second during fast typing. 50ms (20fps) is imperceptible as lag for a cursor.
Disconnect detection
If a user closes the tab, they don't send a goodbye message — the TCP connection just drops. The server detects this via the WebSocket close event and broadcasts a leave notification:
// Server
ws.on('close', () => {
broadcast({ type: 'USER_LEFT', userId: ws.userId });
});
// Client
socket.on('USER_LEFT', ({ userId }) => {
removeRemoteCursor(userId);
});
For extra robustness, clients can set a TTL on each remote cursor: if no CURSOR_UPDATE arrives from user X within 5–10 seconds, remove their cursor. This handles edge cases where the server's disconnect event is delayed or the client's own WebSocket reconnects before receiving the leave broadcast.
Presence is also the right place for "user is typing" indicators in chat — a boolean state broadcast over the socket, not persisted anywhere.
Where this appears
Google Docs — OT over WebSockets. Each keystroke generates an insert or delete operation, sent to the server which transforms and broadcasts it. Cursor positions are broadcast separately as presence events.
Figma — CRDTs (with WebRTC data channels for cursor positions between peers). The canvas state is a CRDT; concurrent frame moves and layer additions merge without server serialization.
Chat (Slack, iMessage) — WebSockets for message delivery and typing indicators. No collaborative editing — messages are immutable once sent, so no conflict resolution is needed.
News feed (Facebook, Twitter) — WebSockets multiplexing new post notifications, reaction counts, and comment counts. The server pushes updates; clients don't write to the feed through the socket.
Excalidraw — CRDTs (Yjs) over WebSockets. Multiple users draw on the same canvas; shape additions and moves merge via CRDT.