Collaborative Editing with CRDTs #

Two people type into the same paragraph at the same moment. With ordinary real-time state sync — send the new value, last write wins — one of them loses their sentence. With naive operation forwarding — send “insert at position 40” and apply it everywhere — the text diverges between screens as soon as edits cross in flight. Collaborative editing needs a data model in which concurrent changes combine, every replica converges to the same result regardless of delivery order, and people can keep typing through a flaky connection or a train tunnel. Conflict-free replicated data types (CRDTs) provide exactly that, and libraries such as Yjs and Automerge make them practical. What the libraries leave to you is the transport: the WebSocket server, its sync handshake, awareness, storage and scaling.

This area covers that transport layer and the decisions around it. It builds on the general state-sync patterns in Frontend Real-Time State Hooks & UI Patterns and on the connection management in Backend WebSocket Connection Management.

The layers of a collaborative editor A collaborative editor stack: editor binding, CRDT document, sync and awareness protocol, WebSocket server with rooms, and persistence with update logs and snapshots. The layers of a collaborative editor Editor binding ProseMirror, TipTap, Lexical, CodeMirror bound to a shared type UI CRDT document Y.Doc: text, maps, arrays; local edits emit binary updates library Sync + awareness protocol state-vector handshake, update relay, ephemeral cursors this area WebSocket server rooms, auth, one doc per room in memory this area Persistence update log + snapshots, compaction this area The CRDT library guarantees convergence; everything below it decides whether edits actually reach every replica
The library merges edits; the transport makes sure every edit arrives.

Prerequisites #

Collaborative editing concentrates several real-time problems in one feature, so a few foundations should be in place first:

  • A WebSocket server with authentication on the upgrade and per-document authorization, as in WebSocket Authentication & Authorization. The CRDT protocol carries no identity of its own.
  • Binary frame support end to end. Yjs updates are compact binary; proxies, logging middleware and message validators must not assume text frames, a concern covered in Message Framing & Serialization.
  • Reconnection with backoff on the client, following Auto-Reconnection Strategies. CRDT sync makes reconnects correct; backoff makes them cheap.
  • A decision about routing: whether all connections for a document land on one server instance, or instances relay updates between themselves.

Core implementation #

The heart of the transport is the sync handshake. When a client connects, it and the server exchange state vectors — compact summaries of which updates each has seen — and each replies with exactly the updates the other lacks. After that, every new local update is sent to the server, applied to the server’s replica, persisted, and relayed to the other clients in the room. Because CRDT updates commute, relaying needs no ordering guarantees beyond eventual delivery.

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';

const WS_URL = 'wss://collab.example.com';

// Client: one Y.Doc per open document, synced over WebSocket and cached locally.
export async function openDocument(docId: string, getTicket: () => Promise<string>) {
const doc = new Y.Doc();

// Local persistence first: the document opens instantly from IndexedDB, even offline.
const local = new IndexeddbPersistence(`doc:${docId}`, doc);
await local.whenSynced;

// Network sync: the provider runs the state-vector handshake on every (re)connect,
// so edits made offline flow to the server and missed edits flow back.
const provider = new WebsocketProvider(WS_URL, docId, doc, {
params: { ticket: await getTicket() }, // short-lived, single-use credential
maxBackoffTime: 10_000,
});

// Awareness: ephemeral per-user state (name, colour, cursor) on the same socket.
provider.awareness.setLocalStateField('user', { name: 'Ada', color: '#1565C0' });

const text = doc.getText('body'); // bind this to the editor
return {
doc, text, provider,
close() { provider.destroy(); local.destroy(); doc.destroy(); },
};
}

The server half — handling sync messages, keeping one document per room, persisting updates and relaying them — is built step by step in syncing Yjs documents over WebSockets. A useful property of the design is that local persistence and network sync are independent: the editor works from the local replica immediately, and the network catches it up whenever it can.

Offline edits converging on reconnect Alice edits offline against her local replica while Bob's edits reach the server; when Alice reconnects the state-vector exchange sends her Bob's edits and sends her offline edits to the server, which relays them to Bob. Offline edits converging on reconnect Alice (offline) Server Bob (online) edits applied to local replica edits relayed and stored reconnect: state vector Bob's edits Alice lacks Alice's offline edits relay Alice's edits Both documents end identical without any conflict dialog
Offline editing is not a special mode — it is just a longer gap between syncs.

Choosing the collaboration model #

Not every shared screen needs a CRDT. The right model depends on the shape of the data and what should happen when two people change it at once.

Independent fields — a form, a settings page, the status and assignee of a ticket — rarely conflict in ways users care about, and when they do, “the later change wins” is what people expect. Per-field last-write-wins with server-assigned versions is simpler, smaller and easier to validate than a CRDT.

Free text and ordered collections — documents, notes, comments being co-written, outline lists, kanban columns being reordered — are where concurrent edits must merge. Last-write-wins throws away someone’s typing; a CRDT sequence type keeps both.

Structured documents — rich text with formatting, nested blocks, embedded tables — need nested shared types. CRDT libraries provide maps, arrays and text that nest, and editor bindings map them onto the editor’s model.

Between CRDTs and operational transform, most new products choose CRDTs for offline support and simpler servers, while OT remains a strong choice where a central authority must validate every operation. The trade-offs are laid out in CRDT vs operational transform for real-time editing.

Scaling collaborative rooms #

A collaborative document is a room with unusually heavy per-room state: the server holds a replica of the document in memory, and every connected editor exchanges updates and awareness with every other. Two scaling questions follow.

Where does a document live? The simplest correct answer is “on exactly one server instance at a time”, achieved by routing connections with consistent hashing on the document id — the technique in WebSocket sticky sessions with nginx ip_hash, keyed on the document rather than the client. Then one in-memory replica serves every editor, persistence happens once per update, and no cross-instance relay is needed. When an instance fails, its documents reload on another instance from storage, and clients resync automatically. The alternative — any instance serves any document, relaying updates through Redis pub/sub — spreads load more evenly but multiplies memory (a replica per instance per document) and persistence writes unless one instance is designated the writer.

How big can a room get? Document updates are small and proportional to typing speed, so even large rooms rarely strain bandwidth for edits. Awareness is the quadratic cost: every cursor movement fans out to every other participant. Throttle awareness, limit which cursors are shown in very large rooms, and consider read-only viewers who receive updates without publishing awareness, as discussed in broadcasting cursor and awareness state.

Traffic in one room, by number of active editors Document update traffic grows linearly with editors while throttled awareness deliveries grow quadratically, reaching twenty-four thousand per second with fifty editors. Traffic in one room, by number of active editors 2 updates/s per typist; awareness throttled to 12/s document updates/s awareness deliveries/s 0 10k 20k 30k 5 editors 3.8k 20 editors 24k 50 editors
Edits scale linearly; awareness scales with the square of the room.

Permissions in shared documents #

CRDTs change where authority lives. In a request/response API, the server sees every change before it takes effect and can refuse it. In a CRDT document, a client applies its own edit locally and then shares it; by the time the server sees the update, the client’s replica already contains it. That shapes how permissions should be designed.

Enforce document-level permissions on the connection. Whether a user may open, edit or only view a document is decided once, at the upgrade, and a read-only connection simply has its incoming document updates dropped by the server (while still receiving everyone else’s). This covers the overwhelming majority of real products and is cheap to implement, following the patterns in per-channel authorization for WebSocket subscriptions.

Avoid fine-grained permissions inside one document — “this user may edit section 3 but not section 4” — wherever you can. Enforcing them means inspecting each update to see which parts of the document it touches, rejecting it, and then sending the author a compensating update so their replica reverts, which is complex and visible to the user as edits snapping back. It is usually better to split content with different permissions into separate documents, each with its own connection-level rule.

When permissions change mid-session, close or downgrade the affected connections from the server. A user whose edit access is revoked should be switched to read-only immediately, which the connection-level design makes a single operation.

Migrating an existing editor #

Teams rarely start collaborative editing from scratch; more often a single-user editor with a “save” button gains real-time collaboration. The migration is smoother in stages. First, move the document model into a CRDT on the client only, keeping the existing save endpoint — the editor now edits a Y.Doc, and saving serializes it. Second, add the WebSocket sync for a small group of users behind a flag, with the server loading the document from the existing storage format on first open and writing both formats during the transition. Third, switch storage to the update log and snapshot layout, and retire the save button once autosave through the sync path has proven reliable. Throughout, keep an export path back to the old format so a rollback never strands documents.

Configuration reference #

Parameter Where Typical value Notes
Room routing key proxy / load balancer document id Consistent hashing keeps one replica per document
maxPayload ws server 16–64 MB First sync of a large document arrives in one frame
Awareness send interval client 50–100 ms About 10–20 updates per second per user
Awareness timeout client + server 30 s Removes peers that vanished without closing
Unload grace period server 30–60 s Keeps a document in memory for quick rejoins
Compaction threshold server 500 updates or 1 MB Folds the update log into a snapshot
Update batching window server 100–500 ms Merge keystrokes before writing to storage
Garbage collection Y.Doc option on (default) Turn off only where version history is required
Provider max backoff client 10 s Reconnect ceiling; sync makes reconnects safe

Edge cases & gotchas #

Loading before syncing. If a server answers a client’s sync request before the document has finished loading from storage, it replies from an empty replica. Late joiners then see an empty document until someone else types. Load first, then accept sync messages.

Echoing updates to their sender. Relaying an update back to the client that produced it is harmless for correctness (applying it twice is a no-op) but doubles traffic. Use the connection as the transaction origin and skip it when relaying.

Treating awareness as data. Persisting cursors or replaying them on reconnect resurrects stale cursors and wastes storage. Awareness is ephemeral by definition.

Unbounded history. A document edited daily for a year accumulates history. Without compaction, load time and first-sync size grow without limit; see persisting CRDT updates on the server.

Validation after the fact. Clients apply their own edits before the server sees them, so a server that rejects an edit must actively revert it and tell the client. Keep fine-grained rules out of CRDT documents where possible, and enforce coarse permissions — who may edit this document at all — on the connection.

Verification #

Convergence is the property everything else serves, so test it directly: simulate several clients making random concurrent edits while the network delays, reorders and drops messages, then heal the network and assert every replica — including the server’s and a replica freshly loaded from storage — is identical.

import * as Y from 'yjs';
// After activity settles, every replica must encode to the same state vector.
function assertConverged(docs: Y.Doc[]) {
const vectors = docs.map((d) => Buffer.from(Y.encodeStateVector(d)).toString('base64'));
const texts = docs.map((d) => d.getText('body').toString());
if (new Set(vectors).size !== 1 || new Set(texts).size !== 1) throw new Error('replicas diverged');
}

In staging, run the manual version: two browsers, one taken offline with DevTools, edits in both, reconnect, compare. In production, track rooms loaded per instance, editors per room, first-sync payload size, update log length per document, and reconnect-with-sync counts; a growing first-sync size or log length is the signal to compact.

Guides in this area #

FAQ #

Do I need a CRDT for real-time collaboration? #

Only where concurrent edits must merge — free text and ordered collections. For independent fields, per-field last-write-wins is simpler. Many products use both: CRDTs for document bodies, plain state sync for metadata.

Can I use CRDTs without a server? #

Yes — CRDTs work peer to peer, for example over WebRTC data channels. In practice a server is still valuable for persistence, authentication and serving late joiners when no peer is online, so most products use one.

Which WebSocket server should I use for Yjs? #

Any server that speaks the y-protocols messages. The reference y-websocket server is a good starting point; hosted and open-source alternatives such as Hocuspocus add authentication hooks and persistence adapters. Building your own relay, as shown in this area, makes sense when you need to integrate with an existing WebSocket stack.

How much memory does a server-side document replica use? #

Roughly the size of the encoded document plus the CRDT’s per-item metadata, which Yjs keeps compact by merging runs of consecutive edits. A text document of a few hundred kilobytes typically costs a small multiple of that in memory. Unloading idle documents after a grace period keeps the total proportional to documents actively being edited, not to every document ever opened.

How do collaborative editors handle undo? #

Undo should revert the local user’s own changes, not the latest change in the document. Yjs’s UndoManager tracks local transactions for exactly this, and editor bindings wire it to the editor’s undo commands.

Back to Frontend Real-Time State Hooks & UI Patterns.