website/src/content/cookbook/collaborative-text-editor.mdx
Patterns for building a Yjs server on RivetKit: CRDT document sync, presence and cursors, and snapshot persistence, with one Rivet Actor per document acting as a relay.
Start with the working example on GitHub and adapt it to your editor. It ships a React frontend with a plain textarea, remote cursor overlays, and a workspace document index.
| Use Case | Starter Code | Common Examples |
|---|---|---|
| Shared document editing | GitHub | Notion-style docs, shared notes, pair-writing tools, form co-editing |
Two families of algorithms solve concurrent text editing. The choice decides what your server has to do.
| Dimension | CRDT (Yjs) | Operational Transformation |
|---|---|---|
| Conflict resolution model | Commutative merges. Updates apply in any order on any peer and converge to the same result. | Server transforms each operation against every concurrent operation. Correctness depends on a central sequencer. |
| Offline support | Strong. Clients keep editing locally and merge buffered updates on reconnect. | Weak. Long-lived divergence makes transformation chains complex and fragile. |
| Server role | Relay plus persistence. The server applies opaque updates and rebroadcasts them. It never needs to understand document semantics. | Authoritative transformer. The server must implement transformation logic for every operation type. |
| Library maturity | Yjs is mature and widely deployed, with bindings for ProseMirror, CodeMirror, Monaco, and others. | Production-grade implementations are mostly proprietary (Google Docs) or aging (ShareDB). |
The example uses Yjs because CRDTs let the server stay a relay-style Rivet Actor. The actor applies each incoming update to a server-side Y.Doc so it can persist the merged state and serve late joiners, but it never transforms operations or arbitrates conflicts. Ordering does not matter because Yjs merges are commutative.
| Topic | Summary |
|---|---|
| Topology | One document[workspaceId, documentId] actor per document plus one documentList[workspaceId] coordinator per workspace. |
| Sync model | Each client holds a local Y.Doc. The document actor relays incremental Yjs updates as broadcast events and keeps a server-side merged copy in vars. |
| Persistence | Full merged Yjs snapshot overwritten in one binary actor KV key (yjs:doc) on every sync update. Document metadata lives in JSON state. |
| Queues | None. The example is purely actions plus broadcast events. |
| Presence | Yjs Awareness relayed through the same applyUpdate action. Per-connection connState tracks asserted awareness clientIds for disconnect cleanup. |
The two-actor split follows the coordinator pattern from Design Patterns: the coordinator owns discovery and creation, and each document actor owns one document's realtime state. Multi-part keys scope both actors to a workspace.
Actors
<AccordionGroup> <Accordion title="document[workspaceId, documentId]">document[workspaceId, documentId]Y.Doc and Awareness, persists the merged Yjs snapshot to actor KV, and broadcasts updates to all connected collaborators.getContentapplyUpdategetAwarenesstitle, createdAt, updatedAtyjs:doc holding the full merged Yjs snapshotY.Doc and Awareness, created in createVars and rehydrated from KV on actor startconnState: clientIds of awareness clients asserted by that connectiondocumentList[workspaceId]createDocumentlistDocumentsdeleteDocumentdocuments array of DocumentSummary entries (id, title, createdAt, updatedAt)The coordinator's createDocument generates a UUID, then explicitly creates the document actor with c.client<typeof registry>() and passes { title, createdAt } as creation input, which the document actor's createState consumes. See Communicating Between Actors for the actor-to-actor client.
A single applyUpdate(update, kind, clientId?) action handles both update kinds. Updates cross the action boundary as number[] byte arrays and are converted back to Uint8Array on each side.
| Kind | Server Applies To | Persists | Broadcasts |
|---|---|---|---|
"sync" | c.vars.doc via Y.applyUpdate with origin "client" | Full merged snapshot to KV key yjs:doc, then bumps updatedAt | sync event carrying the incremental update |
"awareness" | c.vars.awareness via applyAwarenessUpdate with origin "client" | Nothing. Presence is ephemeral. | awareness event carrying the update |
Note the asymmetry on the sync branch: the broadcast carries only the small incremental update, while the KV write stores the full merged document re-encoded with Y.encodeStateAsUpdate.
Yjs origin tags are the echo guards that keep the relay loop-free:
| Origin Tag | Set Where | Effect |
|---|---|---|
"local" | Client edits inside doc.transact(..., "local") | The client's update listener fires and sends applyUpdate to the actor. |
"client" | Server applying an incoming update to its Y.Doc or Awareness | Marks the change as client-originated on the server copy. |
"remote" | Client applying broadcast events or initial sync data | Update listeners early-return on "remote", so a client never re-sends its own echo. |
On connect or reconnect, the client calls getContent and getAwareness, then applies both results to its local Y.Doc and Awareness with origin "remote". After that, every change flows through applyUpdate and the broadcast events.
Presence (user names, colors, cursor positions) rides on the Yjs Awareness protocol instead of actor state:
awareness.setLocalStateField for the user and cursor fields. The awareness update listener encodes the change and sends applyUpdate(update, "awareness", awareness.clientID).clientId in that connection's connState.clientIds, applies the update to the server-side Awareness, and broadcasts the awareness event to all peers. See Connections for per-connection state.onDisconnect reads the connection's clientIds, calls removeAwarenessStates on the server-side Awareness, and broadcasts the encoded removal so every remaining client drops the departed user's cursor. See Lifecycle for the hook.Because the actor tracks which awareness clientIds belong to which connection, presence cleanup is automatic on disconnect with no client cooperation required.
The example persists with a full-snapshot overwrite: on every "sync" update, the actor re-encodes the entire merged document with Y.encodeStateAsUpdate and overwrites the single binary KV key yjs:doc. There is no append-only update log and no separate compaction job. Compaction is implicit because Y.encodeStateAsUpdate emits one compact merged representation of the document, so Yjs merge semantics keep the stored blob compact on their own.
| Property | Full-Snapshot Overwrite (the example) |
|---|---|
| Write cost | One full-document KV write per sync update, so every keystroke rewrites the whole blob. |
| Read cost | One binary KV read in createVars rehydrates the document on actor start. |
| Crash safety | The last completed applyUpdate is durable. No log replay needed. |
| Sweet spot | Small to medium documents where simplicity beats write amplification. |
Recommended extension (not in the example): for large documents or very high edit rates, switch to appending incremental updates to a KV update log and writing a merged snapshot only periodically (for example every N updates). Boot becomes snapshot plus log replay, and the snapshot write becomes the explicit compaction step that truncates the log. Adopt this only when full-snapshot writes become the measured bottleneck or the blob approaches KV value size limits.
sequenceDiagram
participant A as Client A
participant B as Client B
participant DL as documentList
participant D as document
A->>DL: listDocuments()
A->>DL: createDocument(title)
DL->>D: create([workspaceId, documentId], input)
DL-->>A: DocumentSummary
A->>D: connect
B->>D: connect
Note over D: createVars rehydrates Y.Doc from KV "yjs:doc"
A->>D: getContent() + getAwareness()
D-->>A: encoded doc + awareness state
Note over A: local edit with origin "local"
A->>D: applyUpdate(update, "sync")
Note over D: apply with origin "client", overwrite KV snapshot
D-->>B: sync event (incremental update)
Note over B: apply with origin "remote", no echo
A->>D: applyUpdate(update, "awareness", clientId)
D-->>B: awareness event
B-->>D: disconnect
Note over D: onDisconnect removes B's awareness clientIds
D-->>A: awareness event (removal)
The example ships with no authentication or authorization. Harden it with this baseline before production. None of these are implemented in the example.
useActor implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank documentList coordinator. Add connection auth so unauthenticated clients never reach an actor. See Authentication.[workspaceId, documentId] key, not just any document.applyUpdate: Update payloads are unvalidated number[] arrays with no size limit, and the example client sends one action per keystroke and per cursor move with zero throttling. Enforce payload size caps and per-connection rate limits on the server, and debounce on the client.clientId argument to applyUpdate is client-supplied and trusted as-is. Derive or verify presence identity from connection-scoped server state instead.deleteDocument only filters the entry out of the coordinator's index. The document actor and its KV snapshot are orphaned. On delete, also destroy the document actor and its storage, with a permission check on who may delete.