Building a Real-Time Collaborative UI with CRDTs in the Frontend
Building a Real-Time Collaborative UI with CRDTs in the Frontend
Creating a frontend that truly supports simultaneous collaboration-where multiple users can edit the same content at the same time and see each other’s changes in near real-time-has long been a challenging problem. This guide walks you through building a real-time collaborative UI from the ground up using Conflict-free Replicated Data Types (CRDTs). You’ll get practical patterns, concrete code, and a complete integration approach you can adapt to your stack.
What you’ll build
- A simple collaborative text editor that supports multiple cursors, concurrent edits, and conflict-free merges.
- A small data model and CRDT implementation tailored for text editing.
- A front-end architecture that handles local edits, remote updates, and user presence.
- A minimal server-free fallback using WebRTC data channels for peer-to-peer collaboration as an optional path.
- Considerations for performance, UX, and data integrity.
Key concepts at a glance
- CRDTs: Data structures designed so that independent edits from different users converge to the same final state without requiring a central server to resolve conflicts.
- Tombstones and operational semantics: How we handle deletions without losing user intent.
- Local-first and presence: Keeping the UI responsive while integrating remote changes and user cursors.
- Consistency guarantees: Eventually consistent state with deterministic merges.
Architecture overview
- Frontend data model: A CRDT-based text buffer that stores an ordered sequence of characters or blocks with unique, sortable identifiers.
- Local edits: Edits mutate the local CRDT and broadcast operations to peers or server.
- Synchronization layer: A simple message transport that delivers operations to all participants and applies them in a deterministic order.
- Presence layer: Lightweight presence data to render other users’ cursors and selections.
- Optional: Server backend or WebRTC for peer-to-peer transport.
Step 1: Define the CRDT for text editing
We’ll implement a basic sequence CRDT called an RGA (Replicated Growable Array) variant, focusing on practicality for frontend demos. Each character is stored with a unique id and a reference to its predecessor, enabling deterministic merges.
Data model
- Node: { id: string, char: string, prev: string | null, tombstoned?: boolean }
- State: an ordered list formed by traversing nodes via prev pointers from a global head, skipping tombstoned nodes.
- Operation: insert at position, delete by id, or tombstone by id (for simplicity we’ll use tombstone on delete).
Implementation notes
- Each insertion assigns a unique id composed of a clientId and a monotonically increasing counter, plus a timestamp to help debugging.
- To insert, we link the new node after a known predecessor id, storing its id in the node.
- To delete, we tombstone the target id. Tombstoned nodes remain in the graph for correctness but are ignored in rendering.
- Merges are simply combining node sets and taking the union; the order is determined by a deterministic traversal from the head through prev pointers.
Code: CRDT core (TypeScript)
- Create a small portable CRDT module you can reuse in any frontend project.
// crdt-rga.ts
export type NodeId = string;
export interface CRDTNode {
id: NodeId;
char: string;
prev: NodeId | null; // predecessor id
tombstoned?: boolean;
createdAt: number;
}
export interface CRDTModel {
// map of nodes by id
nodes: Record<NodeId, CRDTNode>;
// head is virtual: the first node has prev = null
head: NodeId | null;
// local clock / client id for unique ids
clientId: string;
// simple counter to help generate ids
counter: number;
}
export function createCRDT(clientId: string): CRDTModel {
return {
nodes: {},
head: null,
clientId,
counter: 0,
};
}
function nextId(clientId: string, counter: number): NodeId {
const ts = Date.now().toString(36);
return `${clientId}-${counter}-${ts}`;
}
// Insert a character after a given predecessor id (or after head if null)
export function insertChar(model: CRDTModel, afterId: string | null, ch: string): { newNode: CRDTNode; newModel: CRDTModel } {
const id = nextId(model.clientId, model.counter + 1);
const node: CRDTNode = { id, char: ch, prev: afterId, createdAt: Date.now() };
// clone to mutate immutably
const newNodes = { ...model.nodes, [id]: node };
const newModel: CRDTModel = { ...model, nodes: newNodes, counter: model.counter + 1 };
// we won't adjust head here; traversal handles order
return { newNode: node, newModel };
}
// Delete (tombstone) the node with given id
export function tombstone(model: CRDTModel, id: string): CRDTModel {
if (!model.nodes[id]) return model;
const node = { ...model.nodes[id], tombstoned: true };
return { ...model, nodes: { ...model.nodes, [id]: node } };
}
// Render order: produce a list of non-tombstoned chars in deterministic order
export function renderOrder(model: CRDTModel): { id: string; char: string }[] {
// Build graph: map from id to node, and compute an order by walking from head through successors
// We don't keep explicit successor pointers; instead, we sort by a stable traversal:
// We'll perform a simple, deterministic pass:
// 1. Collect all non-tombstoned nodes
// 2. Sort by createdAt then id to get a stable baseline
// 3. Build an explicit order by chaining prev pointers: start from nodes with prev null (head)
// Since prev can point to any existing node, we reconstruct by a topological-like pass.
const nodes = Object.values(model.nodes).filter(n => !n.tombstoned);
// If there is a single root after head (prev === null)
const roots = nodes.filter(n => n.prev === null);
// Simple approach: perform a stable DFS from roots following an insertion sequence:
// We'll create an adjacency map: after node id, collect children that have prev === node.id
const adj: Record<string, CRDTNode[]> = {};
for (const n of nodes) {
if (!n.prev) continue;
if (!adj[n.prev]) adj[n.prev] = [];
adj[n.prev].push(n);
}
// sort children deterministically by createdAt and id
for (const k of Object.keys(adj)) {
adj[k].sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
return a.id.localeCompare(b.id);
});
}
// Traverse from each root
const result: { id: string; char: string }[] = [];
const visited = new Set<string>();
function dfs(n: CRDTNode) {
if (visited.has(n.id)) return;
visited.add(n.id);
result.push({ id: n.id, char: n.char });
const children = adj[n.id] ?? [];
for (const c of children) dfs(c);
}
// Start from roots in deterministic order
const orderedRoots = roots.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
for (const r of orderedRoots) dfs(r);
// As a fallback, if nothing rendered (empty), return an empty array
return result;
}
Step 2: Frontend integration: a simple editor UI
- We'll create a minimal React-based editor that uses the CRDT core.
- It will handle local input, render the text, and simulate remote updates via a mocked transport.
Code: Editor component (React, TypeScript)
// CollaborativeEditor.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createCRDT, CRDTModel, insertChar, tombstone, renderOrder } from './crdt-rga';
type Presence = { [clientId: string]: { cursor: number; color: string } };
export interface CollaborativeEditorProps {
clientId: string;
onChange?: (text: string) => void;
// transportSend would send serialized operations to peers/servers
transportSend: (payload: any) => void;
// transportSubscribe would provide a handler to apply incoming ops
transportSubscribe: (handler: (payload: any) => void) => void;
}
export function CollaborativeEditor({ clientId, transportSend, transportSubscribe, onChange }: CollaborativeEditorProps) {
const [model, setModel] = useState<CRDTModel>(() => createCRDT(clientId));
const [text, setText] = useState<string>('');
const [presence, setPresence] = useState<Presence>({});
const inputRef = useRef<HTMLInputElement | null>(null);
// Initialize a simple root root: nothing yet
useEffect(() => {
// Subscribe to remote messages
transportSubscribe((payload) => {
// Expecting payload: { type: 'insert'|'delete', ... }
if (!payload) return;
// Very simple merge protocol: re-create local model from ops
// For demonstration, we just apply ops locally
if (payload.type === 'insert') {
const after = payload.afterId ?? null;
const ch = payload.char;
const { newModel } = insertChar(model, after, ch);
setModel(newModel);
commitRender(newModel);
} else if (payload.type === 'delete') {
const id = payload.id;
const newModel = tombstone(model, id);
setModel(newModel);
commitRender(newModel);
}
// Presence updates
if (payload.presence) {
setPresence(payload.presence);
}
});
// Initial render
commitRender(model);
// eslint-disable-next-line
}, []);
function commitRender(m: CRDTModel) {
const rendered = renderOrder(m);
const s = rendered.map(r => r.char).join('');
setText(s);
onChange?.(s);
}
// Simple local typing: insert at end
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
// Insert character after the current end
const afterId = model.nodes[findLastNodeId(model)]?.id ?? null;
const ch = e.key;
const { newModel, newNode } = insertChar(model, afterId, ch);
setModel(newModel);
commitRender(newModel);
// Broadcast to peers
transportSend({ type: 'insert', afterId, char: ch, id: newNode.id, clientId });
// prevent default to avoid double input
// Note: We rely on the browser input value, so prevent default would stop typing;
// In practice, you'd render via controlled contenteditable. Here we demonstrate the core idea.
}
}
function findLastNodeId(m: CRDTModel): string | null {
const nodes = Object.values(m.nodes).filter(n => !n.tombstoned);
if (nodes.length === 0) return null;
// pick the one with greatest createdAt
nodes.sort((a, b) => b.createdAt - a.createdAt);
return nodes.id;
}
// For simplicity: a delete button tombstones the last node
function handleDeleteLast() {
const lastId = findLastNodeId(model);
if (!lastId) return;
const newModel = tombstone(model, lastId);
setModel(newModel);
commitRender(newModel);
transportSend({ type: 'delete', id: lastId, clientId });
}
// Presence simulation: update own cursor (static for demo)
useEffect(() => {
const pres = { [clientId]: { cursor: text.length, color: '#'+((1<<24)*Math.random()|0).toString(16).slice(1,7) } };
setPresence(pres);
transportSend({ presence: pres });
}, [text]);
// Render
return (
<div style={{ border: '1px solid #ddd', padding: 12, borderRadius: 6 }}>
<div style={{ minHeight: 40, padding: 6, border: '1px solid #eee', borderRadius: 4, fontFamily: 'monospace' }}>
{text}
{Object.entries(presence).map(([pid, p]) => (
<span key={pid} style={{ position: 'relative', left: 0, color: p.color, marginLeft: 4 }}>
• {pid}
</span>
))}
</div>
<input
ref={inputRef}
onKeyDown={handleKeyDown}
placeholder="Type to insert (demo)."
style={{ width: '100%', marginTop: 8, padding: 8 }}
/>
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
This is a minimal CRDT-based collaborative editor demonstration. In production, use a robust transport layer and a richer ARIA-compliant UI.
</div>
<button onClick={handleDeleteLast} style={{ marginTop: 8 }}>Delete Last</button>
</div>
);
}
Note: The editor above is intentionally simplified to illustrate the CRDT integration patterns. For a production-ready editor, you’d replace the simple input with a contenteditable surface or a specialized text editor (e.g., ProseMirror, TipTap, or Lexical) and integrate a robust transport layer (WebSocket or WebRTC data channels) with proper operation encoding, sequencing, and conflict resolution.
Step 3: Transport layer: simple broadcast mock
- In a real app, you’d connect to a signaling server, set up WebSocket or WebRTC connections, and broadcast operations.
- For this tutorial, we’ll implement a lightweight in-memory broadcaster to simulate multiple clients in a single page, useful for demos and tests.
Code: Simple in-memory broadcaster (TypeScript)
// in-memory-broadcast.ts
type Handler = (payload: any) => void;
export class InMemoryBroadcast {
private handlers: Handler[] = [];
subscribe(h: Handler) {
this.handlers.push(h);
return () => {
this.handlers = this.handlers.filter(x => x !== h);
};
}
broadcast(payload: any) {
for (const h of this.handlers) h(payload);
}
}
How to wire it up in a quick demo
- Create two or three instances of CollaborativeEditor on the same page, all connected to the same InMemoryBroadcast.
- Each editor uses a distinct clientId and shares a common transportSend and transportSubscribe that push to and listen from the broadcast.
Example usage (pseudo-code)
// App.tsx
import React from 'react';
import { CollaborativeEditor } from './CollaborativeEditor';
import { InMemoryBroadcast } from './in-memory-broadcast';
const bus = new InMemoryBroadcast();
function App() {
const clientAId = 'A';
const clientBId = 'B';
const wrap = (clientId: string) => ({
transportSend: (payload: any) => {
// broadcast to others
bus.broadcast({ ...payload, sender: clientId });
},
transportSubscribe: (handler: (p: any) => void) => {
return bus.subscribe((p) => {
if (p.sender !== clientId) handler(p);
});
},
});
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
<CollaborativeEditor clientId={clientAId} {...wrap(clientAId)} />
<CollaborativeEditor clientId={clientBId} {...wrap(clientBId)} />
</div>
);
}
export default App;
Step 4: UX patterns and best practices
- Presence and cursors: Show other users’ cursors with small colored avatars. Keep the UI responsive by rendering presence data locally and updating it on incoming messages.
- Conflict resolution: With CRDTs, merges are deterministic. Design the operations so that inserts specify the exact predecessor and deletions tombstone, preventing divergent histories.
- Performance: For long documents, avoid re-rendering the entire text on every change. Use a virtualized rendering approach if you extend beyond a few thousand characters.
- Latency hiding: Always surface local edits immediately in the UI; the CRDT ensures remote edits converge. Use optimistic updates with a kind notification when remote operations apply.
- Data persistence: Persist the CRDT state to localStorage or an IndexedDB store so users can rejoin after reload without losing their changes.
Step 5: Testing strategies
- Unit tests for CRDT operations: insertChar, tombstone, renderOrder.
- Integration tests for presence: ensure presence updates flow through the transport and render in the UI.
- End-to-end tests that simulate multiple clients typing concurrently and verify final document state is consistent across clients.
Sample test ideas
- Concurrent inserts: two clients insert at the same logical position; verify final text is the concatenation in a deterministic order.
- Delete and insert interleaving: one user deletes a character while another inserts after it; verify final sequence respects both operations.
- Presence updates: simulate presence payloads and ensure cursors render correctly.
Security and privacy notes
- In peer-to-peer modes, be mindful of data leakage across peers. Consider encryption for the transport layer if sensitive content could be shared.
- Add access control if your app supports multi-room collaboration.
Extending this approach
- Move from a basic RGA to a more robust CRDT family (e.g., OR-Set, Treedoc) to handle more complex editing patterns or richer content than plain text.
- Integrate with a real-time backend like a CRDT-enabled database (e.g., Yjs with WebSocket, Automerge with a server) for production-grade apps.
- Swap the UI to support rich editors (tables, code blocks, markdown) by modeling blocks as CRDT sequences with per-block content as sub-CRDTs.
Common pitfalls to avoid
- Don’t serialize raw DOM events; CRDTs require structured operations that can be deterministically merged.
- Ensure unique id generation is collision-free across clients, especially in mobile environments with disconnections.
- Avoid relying on a single server for ordering; CRDTs rely on commutativity and idempotence of operations.
Illustrative example: a tiny end-to-end scenario
- Alice and Bob both open the editor.
- Alice inserts “H” after the start; Bob inserts “i” after the start concurrently.
- The CRDT assigns distinct ids to both inserted nodes; both operations propagate.
- The renderOrder traverses via prev links, producing “Hi” in a deterministic order, regardless of network timing.
- If Alice deletes the first character later, the tombstone marks that node; both peers render the remaining characters, preserving intent.
What this gives you
- A frontend pattern for real-time collaboration that scales beyond single-user editing.
- A portable CRDT core you can adapt to more complex content and richer interactions.
- A foundation you can pair with production-grade transport layers and robust UIs.
Would you like me to tailor this to a specific frontend framework you’re using (e.g., Next.js, Svelte, Vue) or extend the CRDT to support richer content like markdown blocks or code cells? I can also provide a more production-ready transport layer example with WebSocket signaling and a simple server scaffold.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)