DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Real-Time Collaborative UI with CRDTs in the Frontend

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)