
Building Real-Time Collaborative Applications with CRDTs and WebSockets
Real-time collaborative applications, like Google Docs and Figma, have become critical in 2024 as teams demand seamless, low-latency editing across devices and geographies. Achieving robust collaboration—without data loss or conflicts—requires more than just WebSockets and naive state broadcasting. In this post, I’ll show how to architect, build, and operate production-ready real-time collaborative apps using Conflict-Free Replicated Data Types (CRDTs), WebSockets, and modern full-stack frameworks.
What Are CRDTs and Why Do They Matter for Real-Time Collaboration?
A CRDT (Conflict-Free Replicated Data Type) is a data structure that automatically resolves conflicts in distributed systems, allowing multiple users to edit shared data simultaneously without central coordination. CRDTs guarantee strong eventual consistency: all replicas converge to the same state, even with out-of-order updates or network partitions.
In a collaborative text editor, for example, each user’s changes are represented as operations (insert, delete) on a CRDT list structure. These operations are broadcast to peers via WebSockets. When network conditions cause edits to arrive out of order, the CRDT logic ensures the final document is still correct, with no manual merge logic required.
Here’s an example using the Yjs CRDT library (v13.5.43) in a Node.js WebSocket server:
// server.js (Node.js 18+, Yjs 13.5.43, ws 8.13.0)
const http = require('http');
const WebSocket = require('ws');
const Y = require('yjs');
const { encodeStateAsUpdate, applyUpdate } = Y;
const doc = new Y.Doc();
const server = http.createServer();
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws) => {
// Send current doc state to new client
ws.send(encodeStateAsUpdate(doc));
ws.on('message', (message) => {
applyUpdate(doc, new Uint8Array(message));
// Broadcast change to all clients
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
});
server.listen(3000);
Key insight: CRDTs like Yjs allow you to build merge-free, distributed editing where every client is always eventually consistent—no matter the network conditions.
Step 1: Designing Your Data Model for CRDT Backing
Why Traditional Models Fail Under Concurrency
Relational and document databases (e.g., PostgreSQL, MongoDB) weren't designed for concurrent, decentralized edits. They rely on locking or centralized conflict resolution, both of which break down in real-time, high-latency scenarios. In 2024, users expect sub-100ms feedback even across continents.
How to Model Collaborative State with CRDTs
Define your shared state (e.g., text, lists, trees) using CRDT-backed data structures. For a collaborative whiteboard:
- Use a Y.Map for top-level entities (e.g., shapes by ID)
- Use Y.Array for ordered collections (e.g., points in a polyline)
- Use Y.Text for rich text objects
Yjs and Automerge (v2.0.1) both support these CRDT types. Yjs offers better performance for large, multi-user docs, while Automerge is easier to integrate with React (via automerge-repo).
// Example: Yjs data structure for a collaborative whiteboard
const whiteboard = doc.getMap('shapes');
whiteboard.set('shape-1', new Y.Map({ type: 'rect', x: 10, y: 20 }));
Key insight: Model every collaborative object as a CRDT data structure to unlock true concurrent, offline-tolerant editing.
Step 2: Real-Time Sync via WebSockets and Broadcast Channels
Setting Up WebSocket Infrastructure
WebSockets (RFC 6455) provide low-latency, bidirectional communication ideal for real-time collaboration. For full-stack Node.js apps, I recommend the ws (v8.13.0) library. In production, always terminate WebSockets behind a reverse proxy supporting sticky sessions (e.g., NGINX, ALB with AWS Lambda, or Cloudflare Workers for edge presence).
Example NGINX config for proxying WebSockets:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name collab.example.com;
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
Peer-to-Peer Sync for Edge/Offline
For global scale or offline-first, combine WebSocket server with peer-to-peer (P2P) sync using WebRTC and the BroadcastChannel API (supported in all major browsers as of Chrome 120+). Yjs supports this out-of-the-box with y-webrtc (v10.0.10).
Key insight: Hybrid WebSocket + P2P sync lets you serve low-latency collaboration at global scale, even when users go offline.
Step 3: Persisting and Versioning State Securely
Why You Can’t Trust Client-Only State
Real-time CRDT state is ephemeral and memory-resident. Without server-side persistence, users lose data on disconnect, and you can’t audit or roll back changes. In 2024, compliance (e.g., SOC2, GDPR) demands audit logs and recovery.
How to Persist CRDT State
Periodically serialize the CRDT document and write it to durable storage. For Yjs, use the y-leveldb (v1.2.0) adapter for server-side persistence, or y-dynamodb (if on AWS). For multi-tenant SaaS, use a composite key per document and user. Retain deltas (updates) for conflict resolution and auditing:
// Persist Yjs doc state every 10 seconds
doc.on('update', (update) => {
persistUpdateToDynamoDB(docId, update);
});
For versioning, store snapshots every N minutes and all deltas since last snapshot (pattern: Event Sourcing).
Key insight: Always persist CRDT state and update logs to a durable backend for reliability, auditing, and recovery.
Step 4: Integrating with Modern Frontends (React, Vue, Svelte)
Wiring Up CRDTs to UI State
CRDTs emit granular events (insert, delete, update) you can bind directly to React state (via useEffect), or to Vue's reactivity API. The yjs-react (v2.0.1) and automerge-repo-react packages provide idiomatic bindings.
Example: Collaborative text editor in React (Yjs + yjs-react):
import { useYText } from 'yjs-react';
const [text, setText] = useYText(doc.getText('editor'));
<input value={text} onChange={e => setText(e.target.value)} />
Handling User Presence and Cursors
For UX parity with Google Docs, track user cursors and presence using a Y.Map or Automerge map per user ID, synced via CRDT updates.
Key insight: Use CRDT-reactive bindings for instant UI updates and presence indicators, minimizing manual state management.
Step 5: Scaling and Securing Collaborative Backends
Horizontal Scaling with Stateless WebSocket Servers
WebSockets typically require sticky sessions, but CRDT updates are small (often <1KB per op). In production, use Redis Pub/Sub (v7+) or AWS ElastiCache to fan out updates between stateless WebSocket servers.
Example Redis setup for multi-instance sync:
const redis = require('redis').createClient();
wss.on('connection', (ws) => {
redis.subscribe('crdt-updates');
ws.on('message', (msg) => {
redis.publish('crdt-updates', msg);
});
redis.on('message', (_, msg) => {
ws.send(msg);
});
});
Authentication and Authorization
Authenticate users via JWT (with short TTL) on connection. Authorize CRDT operations server-side—never trust client metadata. For OAuth2 in Node.js, use the oidc-provider (v7.15.1) library.
Key insight: Stateless backends plus centralized pub/sub enable global scale and resilience for collaborative workloads.
Tool Comparison: CRDT Libraries and Collaboration Backends
| Tool / Service | Strengths | Limitations | Production Notes |
|---|---|---|---|
| Yjs (v13.5.43) | Fast, binary-encoded, rich ecosystem | Steeper learning curve | Best for large docs, P2P |
| Automerge (v2.0.1) | Simple API, React-friendly, JSON-based | Slower, larger network payloads | Good for small/medium docs |
| Liveblocks | Managed backend, out-of-box presence/cursor | Usage-based pricing, closed source | Fastest to production |
| Fluid Framework | Enterprise features, MS ecosystem | Complex setup, heavy dependencies | Best for Office365/Teams |
| Firebase Realtime DB | Easy setup, presence, websockets | No CRDT, manual conflict resolution | Not suitable for complex merges |
| Supabase Realtime | Postgres-based, open source | No native CRDT, limited scaling | Great for simple use cases |
Key insight: Yjs and Automerge dominate open-source CRDT collaboration; Liveblocks and Fluid fit managed/enterprise needs, while Firebase lacks true CRDT semantics.
Frequently Asked Questions
Q: What is a CRDT and why is it better than OT (Operational Transformation)? A: A CRDT (Conflict-Free Replicated Data Type) is a distributed data structure that ensures all replicas converge to the same state without central coordination. Unlike OT (used by Google Docs), CRDTs are easier to reason about, support true peer-to-peer sync, and are more robust under network partitions.
Q: How do I scale WebSocket servers for global collaboration? A: Use stateless WebSocket servers behind a load balancer, with Redis Pub/Sub or AWS ElastiCache to synchronize CRDT updates between instances. For global edge presence, deploy via Cloudflare Workers or AWS Global Accelerator.
Q: Can I persist collaborative state in a traditional SQL or NoSQL database? A: Yes, but you should serialize and persist the binary CRDT state (and operation logs) rather than try to map CRDT data structures directly to tables or documents. Use adapters like y-leveldb, y-dynamodb, or blob storage.
Key Takeaways
- Always use CRDT-backed data models for real-time, multi-user collaborative apps to avoid merge conflicts and support offline editing.
- Combine WebSockets (for low-latency server sync) with BroadcastChannel/WebRTC (for P2P and edge performance) in your real-time pipeline.
- Persist CRDT state and deltas to durable storage for resilience, auditability, and disaster recovery.
- Use stateless WebSocket backends with Redis/AWS Pub/Sub to scale collaboration horizontally.
- Integrate CRDTs with frontend frameworks via reactive bindings (yjs-react, automerge-repo-react) for seamless UI updates and presence.
- Yjs (open source) is the most production-proven CRDT library for high-scale, complex documents; Liveblocks and Fluid Framework lead in managed and enterprise scenarios.


