Skip to main content
Realtime

Collaborative Text Editor (CRDT)

Build a collaborative text editor such as Google Docs or Notion with Yjs, where one Actor per document relays edits and presence and saves the document.

See the full example on GitHub. View on GitHub
Editor Atypes an editEditor Bsees the editdocumentListworkspace indexdocumentlive Y.DocActor KVyjs:doc snapshotcreateDocument1create2applyUpdate3kv.put4sync and awareness events5One per documentRivet ActorCallReply

What we’ll build:

  • A document Actor per document that keeps the merged Yjs document and relays every edit to the other editors.
  • Live cursors and names over the Yjs Awareness protocol, cleaned up when someone closes the tab.
  • A snapshot of the document in the Actor’s KV storage, so it survives restarts.
  • A documentList Actor per workspace that creates documents and lists them.
  • A React editor that syncs a local Y.Doc with the Actor.

This guide uses a CRDT. Yjs merges edits in any order on any client, so the server only relays and stores updates and never has to understand them. The other approach is operational transformation (OT), where one server orders and rewrites every operation. It suits editors that need a single authoritative order, but the server has to implement a transform for every operation type and long offline sessions are hard to merge. See Collaborative Text Editor (OT).

Install

npm add rivetkit @rivetkit/react yjs y-protocols

Create the document Actor

Each document is its own document Actor, keyed by [workspaceId, documentId]:

export const document = actor({
	// Track client awareness IDs per connection.
	connState: {
		clientIds: [] as number[],
	},
	events: {
		sync: event<SyncEvent>(),
		awareness: event<AwarenessEvent>(),
	},

	// Persistent metadata that survives restarts: https://rivet.dev/docs/actors/state
	createState: (_c, input: DocumentInput): DocumentState => ({
		title: input.title,
		createdAt: input.createdAt,
		updatedAt: input.createdAt,
	}),

	// Load Yjs state from durable KV storage.
	createVars: async (c) => {
		const doc = new Y.Doc();
		const stored = await c.kv.get("yjs:doc", { type: "binary" });
		if (stored) {
			Y.applyUpdate(doc, stored);
		}
		const awareness = new Awareness(doc);
		return { doc, awareness };
	},
  • createState holds only metadata: title, createdAt, and updatedAt. It reads the title from the creation input that documentList passes in.
  • The live Y.Doc and its Awareness live in vars, because they are class instances that cannot be saved as JSON state. createVars rebuilds them every time the Actor starts by loading the binary yjs:doc key from KV storage.
  • connState gives every connection its own list of awareness client ids, which is used to clean up presence on disconnect.
  • The multi-part key scopes the document to its workspace, so two workspaces can never reach each other’s documents by id.

Relay Yjs updates

Clients sync through one action and one event:

actions: {
	getContent: (c) => {
		const update = Y.encodeStateAsUpdate(c.vars.doc);
		return toNumbers(update);
	},
	applyUpdate: async (
		c,
		update: number[],
		kind: UpdateKind,
		clientId?: number,
	) => {
		if (kind === "sync") {
			const bytes = toBytes(update);
			Y.applyUpdate(c.vars.doc, bytes, "client");
			const snapshot = Y.encodeStateAsUpdate(c.vars.doc);
			await c.kv.put("yjs:doc", snapshot);
			c.state.updatedAt = Date.now();
			c.broadcast("sync", { update });
			return;
		}

		const bytes = toBytes(update);
		applyAwarenessUpdate(c.vars.awareness, bytes, "client");
		if (typeof clientId === "number" && c.conn) {
			const knownIds = c.conn.state.clientIds;
			if (!knownIds.includes(clientId)) {
				knownIds.push(clientId);
			}
		}
		c.broadcast("awareness", { update });
	},
	getAwareness: (c) => {
		const clients = Array.from(c.vars.awareness.getStates().keys());
		const update = encodeAwarenessUpdate(c.vars.awareness, clients);
		return toNumbers(update);
	},
},
  • getContent returns the whole merged document as one Yjs update. A client calls it once on connect to catch up.
  • applyUpdate(update, "sync") applies a client’s change to the server’s Y.Doc with origin "client" and broadcasts the same small update as the sync event. The server never transforms or orders edits. Yjs merges are commutative, so every client ends up with the same text no matter which update arrives first.
  • Updates cross the action boundary as number[], because action arguments are serialized. toBytes and toNumbers convert them back and forth on both sides.
  • The broadcast reaches the sender too. The client applies it with origin "remote", which Yjs ignores because it already has the change, and the client’s listener skips "remote" updates, so nothing is sent twice.

applyUpdate accepts any number[] of any size, and the example client sends one action per keystroke. In production, cap the payload size, rate limit each connection, and batch edits on the client.

Share presence

Names, colors, and cursor positions travel over the Yjs Awareness protocol, through the same action with kind "awareness":

onDisconnect: (c, conn) => {
	const clientIds = conn.state.clientIds;
	if (clientIds.length === 0) {
		return;
	}
	removeAwarenessStates(c.vars.awareness, clientIds, "disconnect");
	const update = encodeAwarenessUpdate(c.vars.awareness, clientIds);
	c.broadcast("awareness", { update: toNumbers(update) });
	conn.state.clientIds = [];
},

actions: {
	getContent: (c) => {
		const update = Y.encodeStateAsUpdate(c.vars.doc);
		return toNumbers(update);
	},
	applyUpdate: async (
		c,
		update: number[],
		kind: UpdateKind,
		clientId?: number,
	) => {
		if (kind === "sync") {
			const bytes = toBytes(update);
			Y.applyUpdate(c.vars.doc, bytes, "client");
			const snapshot = Y.encodeStateAsUpdate(c.vars.doc);
			await c.kv.put("yjs:doc", snapshot);
			c.state.updatedAt = Date.now();
			c.broadcast("sync", { update });
			return;
		}

		const bytes = toBytes(update);
		applyAwarenessUpdate(c.vars.awareness, bytes, "client");
		if (typeof clientId === "number" && c.conn) {
			const knownIds = c.conn.state.clientIds;
			if (!knownIds.includes(clientId)) {
				knownIds.push(clientId);
			}
		}
		c.broadcast("awareness", { update });
	},
	getAwareness: (c) => {
		const clients = Array.from(c.vars.awareness.getStates().keys());
		const update = encodeAwarenessUpdate(c.vars.awareness, clients);
		return toNumbers(update);
	},
},
  • applyUpdate(update, "awareness", clientId) applies the change to the server’s Awareness, remembers clientId in that connection’s connState, and broadcasts the awareness event. Presence is never saved.
  • getAwareness returns everyone’s current presence, so a client that joins late sees the other cursors right away.
  • onDisconnect removes every awareness client the connection asserted and broadcasts the removal, so the cursor disappears even if the tab closed without saying goodbye. See Connections.

The clientId comes from the client and is trusted as is, so one connection could remove another user’s cursor by claiming its id. In production, check it against state the server owns for that connection.

Persist snapshots

Every "sync" update also saves the whole document:

getContent: (c) => {
	const update = Y.encodeStateAsUpdate(c.vars.doc);
	return toNumbers(update);
},
applyUpdate: async (
	c,
	update: number[],
	kind: UpdateKind,
	clientId?: number,
) => {
	if (kind === "sync") {
		const bytes = toBytes(update);
		Y.applyUpdate(c.vars.doc, bytes, "client");
		const snapshot = Y.encodeStateAsUpdate(c.vars.doc);
		await c.kv.put("yjs:doc", snapshot);
		c.state.updatedAt = Date.now();
		c.broadcast("sync", { update });
		return;
	}
  • Y.encodeStateAsUpdate encodes the merged document as one compact update, and c.kv.put overwrites the single yjs:doc key with it. There is no update log, so there is nothing to replay on start and no separate compaction job. Encoding the merged state is the compaction.
  • The write finishes before the broadcast, so an edit that other clients have seen is already saved. If the Actor crashes or moves, createVars loads the last snapshot.
  • Every keystroke rewrites the whole document. That is simple and fine for small and medium documents.

A single KV value is limited to 128 KiB (see Limits). Once the encoded document grows past that, kv.put throws, so applyUpdate fails before the broadcast and edits are neither saved nor relayed.

For large documents or high edit rates, append each incremental update to an update log under its own key and write a merged snapshot only every N updates, then delete the log entries it covers. Start then loads the snapshot and replays the log. Only switch once the full-snapshot write is the measured bottleneck or the document nears the value limit.

Index documents per workspace

A documentList Actor per workspace creates documents and lists them. The registry starts both Actors:

export const documentList = actor({
	// One coordinator per workspace that indexes document actors.
	state: {
		documents: [] as DocumentSummary[],
	},

	actions: {
		createDocument: async (c, title: string) => {
			const documentId = randomUUID();
			const createdAt = Date.now();
			const workspaceId = c.key[0] ?? "default";
			const safeTitle = title.trim() || "Untitled document";

			const client = c.client<typeof registry>();
			const handle = await client.document.create(
				[workspaceId, documentId],
				{
					input: { title: safeTitle, createdAt },
				},
			);
			await handle.resolve();

			const summary: DocumentSummary = {
				id: documentId,
				title: safeTitle,
				createdAt,
				updatedAt: createdAt,
			};
			c.state.documents.push(summary);
			return summary;
		},
		listDocuments: (c) => c.state.documents,
		deleteDocument: (c, documentId: string) => {
			c.state.documents = c.state.documents.filter(
				(document) => document.id !== documentId,
			);
			return true;
		},
	},
});

// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
	use: { document, documentList },
});

registry.start();
  • This is the coordinator pattern from Design Patterns: documentList owns discovery, and each document Actor owns one document’s realtime state.
  • createDocument creates the document Actor explicitly with c.client<typeof registry>() and passes { title, createdAt } as input. See Communicating Between Actors.
  • deleteDocument only removes the entry from the list. The document Actor and its snapshot stay behind. To delete a document for real, also destroy the Actor.

The example has no authentication. Anyone who knows a workspace id can list and open its documents, and connecting with a new workspace id creates an empty documentList. In production, authenticate connections and check that the user may open each [workspaceId, documentId]. See Authentication and Permissions.

Connect the editor

Each open document creates its own Y.Doc in the browser and sends every local change to the Actor:

const doc = new Y.Doc();
const text = doc.getText("content");
const awareness = new Awareness(doc);
docRef.current = doc;
textRef.current = text;
awarenessRef.current = awareness;

const handleDocUpdate = (update: Uint8Array, origin: unknown) => {
	setContent(text.toString());
	if (origin === "remote") {
		return;
	}
	const connection = connectionRef.current;
	if (!connection) {
		return;
	}
	connection.applyUpdate(toNumbers(update), "sync");
};

Then, every time the connection opens, it catches up and listens for changes from others:

useEffect(() => {
	const connection = documentActor.connection;
	const doc = docRef.current;
	const awareness = awarenessRef.current;
	const text = textRef.current;
	if (!connection || !doc || !awareness || !text) {
		return;
	}

	let cancelled = false;

	connection
		.getContent()
		.then((update) => {
			if (cancelled) {
				return;
			}
			Y.applyUpdate(doc, toBytes(update), "remote");
			setContent(text.toString());
		})
		.catch(() => null);

	connection
		.getAwareness()
		.then((update) => {
			if (cancelled) {
				return;
			}
			applyAwarenessUpdate(awareness, toBytes(update), "remote");
			setPresence(buildPresence(awareness));
		})
		.catch(() => null);

	return () => {
		cancelled = true;
	};
}, [documentActor.connection]);

documentActor.useEvent("sync", (event: SyncEvent) => {
	const doc = docRef.current;
	if (!doc) {
		return;
	}
	Y.applyUpdate(doc, toBytes(event.update), "remote");
});

documentActor.useEvent("awareness", (event: AwarenessEvent) => {
	const awareness = awarenessRef.current;
	if (!awareness) {
		return;
	}
	applyAwarenessUpdate(awareness, toBytes(event.update), "remote");
	setPresence(buildPresence(awareness));
});
  • useActor({ name: "document", key: [workspaceId, document.id] }) connects to the document’s Actor. The text lives in doc.getText("content").
  • Typing calls doc.transact(..., "local"), which fires handleDocUpdate, which sends the update with applyUpdate. Changes from the Actor are applied with origin "remote", so they update the screen but are not sent back.
  • On connect and reconnect, the client applies getContent and getAwareness before the sync and awareness events take over. The awareness listener sends local cursor moves the same way as edits.
  • Edits made while the connection is down stay in the local Y.Doc and are not sent on reconnect. To keep them, send Y.encodeStateAsUpdate(doc) after the catch-up, and Yjs merges whatever the Actor is missing.

The example edits a plain <textarea> and replaces the whole text on every change, so two people typing at the same moment can end up with both versions of the text. A real editor should use a Yjs binding that applies small edits, such as y-prosemirror, y-codemirror, or y-monaco. The Actor does not change.

Try it

git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/collaborative-document
pnpm install
pnpm dev

Open http://localhost:5173 in two tabs and change the Name in one of them. Click Create in the first tab to make a document. The list does not update live, so reload the second tab and click Open on the new document. Type in either tab and the text shows up in both, with the other person’s cursor marked by their name and listed under Active collaborators.

Next steps