Quick answer: Yjs is a CRDT (Conflict-free Replicated Data Type) engine that automatically synchronizes state across multiple clients without manual merge conflicts. In production, the sync server acts as a central coordinator, persisting updates to a database and broadcasting changes to all connected clients via WebSocket. Correct architecture requires: (1) a robust WebSocket server with connection pooling, (2) durable persistence on PostgreSQL/Redis, (3) monitoring metrics to detect sync lag, (4) crash recovery via immutable update log. This article walks you through designing and implementing a production-ready sync server with Yjs, including providers, scalability, and debugging strategies.
Foundations: What are CRDTs and why Yjs
A CRDT (Conflict-free Replicated Data Type) is a data structure that guarantees automatic state convergence across independent replicas, even with offline, latency, or arbitrary message ordering. Unlike traditional operational systems (OT — Operational Transform) that require an authoritative server, CRDTs work peer-to-peer and are ideal for local-first architectures.
Yjs is the most mature CRDT library for JavaScript: it supports arrays, maps, text (with cursors), incremental synchronization, local persistence via IndexedDB, and pluggable providers (WebSocket, WebRTC, IPFS). Yjs binary serialization is ~10x more compact than JSON, significantly reducing network traffic.
In a local-first architecture, each client maintains a complete copy of state locally (IndexedDB), syncs only deltas via WebSocket, and remains fully functional offline. When the network returns, the sync server reconciles all concurrent changes without conflicts.
Sync server architecture
A production-ready Yjs sync server has three critical responsibilities:
- Coordination: track WebSocket connections, send updates to all interested clients, manage join/leave of new clients
- Durable persistence: save every Yjs update to a database (PostgreSQL) for server crash survival, plus an immutable changelog for recovery
- Horizontal scaling: support multiple server instances behind a load balancer, syncing state via Redis pub/sub
The recommended topology is:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client A │ │ Client B │ │ Client C │
│ (IndexedDB) │ │ (IndexedDB) │ │ (IndexedDB) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┼───────────────────┘
WebSocket (TLS)
│
┌─────────────────┼─────────────────┐
│ │ │
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│Server A │ │Server B │ │Server C │
│(Yjs Doc) │ │(Yjs Doc) │ │(Yjs Doc) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└─────────────────┼─────────────────┘
Redis pub/sub (inter-server sync)
│
┌───────────┼───────────┐
│ │ │
┌────▼─────┐ ┌──▼────┐ ┌───▼────┐
│PostgreSQL │ │ Redis │ │Changelog
│(Snapshot) │ │(Cache)│ │(WAL)
└──────────┘ └───────┘ └────────┘
Implementation: WebSocket server with y-websocket-server
The standard provider for Yjs is y-websocket-server, which handles broadcasting of updates. Installation:
npm install yjs y-websocket y-websocket-server ws
A minimal server in Node.js (Express + WebSocket):
import express from 'express';
import { WebSocketServer } from 'ws';
import * as Y from 'yjs';
import { setupWSConnection, setPersistence } from 'y-websocket-server';
const app = express();
const server = require('http').createServer(app);
const wss = new WebSocketServer({ server });
// Global map of Yjs documents (in-memory)
// In production, load from PostgreSQL on first access
const docs = new Map();
// Function to get or create a document
function getDoc(docName) {
if (!docs.has(docName)) {
const ydoc = new Y.Doc();
docs.set(docName, ydoc);
// Load snapshot from PostgreSQL (pseudo-code)
// const snapshot = await db.query('SELECT data FROM ydocs WHERE name = ?', [docName]);
// if (snapshot) Y.applyUpdate(ydoc, snapshot);
}
return docs.get(docName);
}
wss.on('connection', (ws, req) => {
// Extract docName from URL: ws://server/sync/mydoc
const url = new URL(req.url, `http://${req.headers.host}`);
const docName = url.pathname.split('/').pop();
const ydoc = getDoc(docName);
// Setup synchronization
setupWSConnection(ws, req, docName, ydoc);
});
server.listen(1234, () => {
console.log('Yjs sync server running on :1234');
});
Persistence: saving updates to PostgreSQL
For durability, every Yjs update must be saved to PostgreSQL. The strategy is:
- Save the state vector and binary update to a changelog table
- Periodically (every N updates or every T seconds), generate a snapshot by compressing the changelog
- On crash, reload the snapshot + replay subsequent logs
import pg from 'pg';
const pool = new pg.Pool({
connectionString: 'postgresql://user:pass@localhost/yjs_sync'
});
// SQL schema (Flyway migration):
// CREATE TABLE ydocs_updates (
// id BIGSERIAL PRIMARY KEY,
// doc_name VARCHAR(255) NOT NULL,
// state_vector BYTEA NOT NULL,
// update_data BYTEA NOT NULL,
// created_at TIMESTAMP DEFAULT NOW()
// );
// CREATE TABLE ydocs_snapshots (
// doc_name VARCHAR(255) PRIMARY KEY,
// data BYTEA NOT NULL,
// state_vector BYTEA NOT NULL,
// last_update_id BIGINT NOT NULL,
// created_at TIMESTAMP DEFAULT NOW()
// );
// When you receive an update via WebSocket:
async function persistUpdate(docName, update, stateVector) {
const client = await pool.connect();
try {
await client.query(
'INSERT INTO ydocs_updates (doc_name, state_vector, update_data) VALUES ($1, $2, $3)',
[docName, stateVector, update]
);
} finally {
client.release();
}
}
// Generate snapshot periodically (e.g., every 500 updates):
async function snapshotIfNeeded(docName, ydoc, updateCount) {
if (updateCount % 500 === 0) {
const client = await pool.connect();
try {
const data = Y.encodeStateAsUpdate(ydoc);
const stateVector = Y.encodeStateVector(ydoc);
const lastUpdateId = await client.query(
'SELECT MAX(id) as max_id FROM ydocs_updates WHERE doc_name = $1',
[docName]
);
await client.query(
`INSERT INTO ydocs_snapshots (doc_name, data, state_vector, last_update_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (doc_name) DO UPDATE SET
data = $2, state_vector = $3, last_update_id = $4, created_at = NOW()`,
[docName, data, stateVector, lastUpdateId.rows[0].max_id]
);
} finally {
client.release();
}
}
}
Synchronization across multiple servers (Redis pub/sub)
If you have N servers behind a load balancer, you must synchronize updates between servers via Redis pub/sub. Here's how:
- Client A connected to Server 1 sends an update
- Server 1 applies the update to its local ydoc, persists to PostgreSQL, and publishes to Redis
- Server 2 and 3 subscribed to the Redis channel receive the update, apply it to their ydoc, and broadcast to connected clients
import redis from 'redis';
const redisPub = redis.createClient();
const redisSub = redis.createClient();
// When an update arrives via WebSocket on Server 1:
function handleUpdate(docName, update, stateVector, ws) {
const ydoc = getDoc(docName);
Y.applyUpdate(ydoc, update);
// Save to PostgreSQL
persistUpdate(docName, update, stateVector);
// Publish to Redis for other servers
redisPub.publish(`yjs:${docName}`, JSON.stringify({
type: 'update',
update: update.toString('base64'),
stateVector: stateVector.toString('base64')
}));
// Broadcast to clients connected to this server
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(update);
}
});
}
// Subscribe on Redis to receive updates from other servers:
redisSub.subscribe('yjs:*', (message, channel) => {
const docName = channel.split(':')[1];
const payload = JSON.parse(message);
if (payload.type === 'update') {
const ydoc = getDoc(docName);
const update = Buffer.from(payload.update, 'base64');
Y.applyUpdate(ydoc, update);
// Broadcast to clients of this server
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(update);
}
});
}
});
Client-side: IndexeddbPersistence and WebSocket provider
On the client side (Angular/React), the library provides two essential providers:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
// Create the Yjs document
const ydoc = new Y.Doc();
// Persist locally to IndexedDB
const indexeddbProvider = new IndexeddbPersistence('doc-name', ydoc);
// Sync with server via WebSocket
const websocketProvider = new WebsocketProvider(
'wss://sync.example.com',
'doc-name',
ydoc
);
// Observable of sync state
websocketProvider.on('connection-close', () => {
console.log('Disconnected, working offline');
});
websocketProvider.on('connection-open', () => {
console.log('Connected to sync server');
});
// Reflect updates in the DOM (e.g., with Svelte, Preact, or Angular signals)
const ytext = ydoc.getText('shared-text');
ytext.observe(event => {
console.log('Text updated:', ytext.toString());
});
export { ydoc, ytext, websocketProvider };
Connection management and offline-first
Yjs natively enables offline-first: clients remain functional offline thanks to IndexedDB. When the network returns, synchronization restarts automatically. The key is not to block the UI during sync:
// In Angular, use a reactive signal:
import { signal } from '@angular/core';
export class DocService {
readonly syncStatus = signal<'synced' | 'syncing' | 'error'>('syncing');
readonly connectionStatus = signal<'online' | 'offline'>('offline');
constructor() {
this.websocketProvider.on('sync', (isSynced: boolean) => {
this.syncStatus.set(isSynced ? 'synced' : 'syncing');
});
this.websocketProvider.on('connection-close', () => {
this.connectionStatus.set('offline');
});
this.websocketProvider.on('connection-open', () => {
this.connectionStatus.set('online');
});
}
}
// In template:
// <div *ngIf="docService.syncStatus() === 'syncing'">Syncing...</div>
Monitoring and metrics
To monitor a sync server in production, track:
- Sync lag: difference between client update timestamp and server application time. Target: <100ms under normal conditions.
- Changelog size: grows with number of updates. If it grows indefinitely, increase snapshot frequency.
- Active WebSocket connections: number of connected clients in real-time.
- Persistence errors: PostgreSQL query failures (retry logic, dead letter queue).
- Disk space: binary changelog consumes space; monitor and archive old data.
import prometheus from 'prom-client';
const syncLagHistogram = new prometheus.Histogram({
name: 'yjs_sync_lag_ms',
help: 'Time from client update to server apply (ms)',
buckets: [10, 50, 100, 250, 500]
});
const activeConnectionsGauge = new prometheus.Gauge({
name: 'yjs_active_connections',
help: 'Number of active WebSocket connections'
});
const changelogSizeGauge = new prometheus.Gauge({
name: 'yjs_changelog_size_bytes',
help: 'Size of update changelog (bytes)',
labelNames: ['doc_name']
});
// Record lag when an update is applied:
function handleUpdate(docName, update, clientTimestamp) {
const serverTimestamp = Date.now();
syncLagHistogram.observe(serverTimestamp - clientTimestamp);
// ... rest of handler
}
Troubleshooting: debugging and recovery
Common issues and solutions:
Client and server diverge
Cause: updates lost during a disconnection. Solution: The client IndexeddbPersistence stores all local updates. When the network returns, the WebsocketProvider calculates the diff (state vector) between server and client, syncing only missing changes.
To verify manually: in browser DevTools, open IndexedDB > yjs-sync-storage > updates and check that the latest updates are present.
Server memory grows indefinitely
Cause: the server keeps Yjs documents in memory without ever releasing them. Solution: implement a garbage collector that unloads documents not accessed for X minutes:
const DOC_TTL_MS = 30 * 60 * 1000; // 30 minutes
const lastAccessedTime = new Map();
function getDoc(docName) {
lastAccessedTime.set(docName, Date.now());
// ...
}
// Periodic cleanup:
setInterval(() => {
const now = Date.now();
for (const [docName, lastTime] of lastAccessedTime) {
if (now - lastTime > DOC_TTL_MS) {
docs.delete(docName);
lastAccessedTime.delete(docName);
console.log(`Unloaded doc: ${docName}`);
}
}
}, 5 * 60 * 1000); // Every 5 minutes
Merge conflicts (or state divergence between clients)
CRDT by design avoids merge conflicts. If you notice divergent state between clients, the cause is almost always:
- One of the clients did not receive an update (check WebSocket logs)
- The snapshot on PostgreSQL is corrupted (requires manual reset)
- Clock skew between servers (sync clocks via NTP)
Loss of updates on server crash
Solution: save updates to PostgreSQL BEFORE applying them to the ydoc in memory. Use an atomic transaction:
async function handleUpdateAtomic(docName, update, stateVector) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Save the update
await client.query(
'INSERT INTO ydocs_updates (doc_name, state_vector, update_data) VALUES ($1, $2, $3)',
[docName, stateVector, update]
);
// 2. Apply to local ydoc
const ydoc = getDoc(docName);
Y.applyUpdate(ydoc, update);
// 3. Commit
await client.query('COMMIT');
// 4. Only after commit, broadcast to Redis and clients
broadcastUpdate(docName, update);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Best practices for production
- TLS/WSS mandatory: always use secure WebSocket (wss://) in production. Use Let's Encrypt certificates on Nginx/reverse proxy.
- Connection pooling: use libraries like pg to manage PostgreSQL connections. Default 20 pool size, 5 seconds idle timeout.
- Rate limiting: limit updates per client (e.g., max 10 updates/second). Prevents flooding and abuse.
- Authentication: verify JWT on WebSocket upgrade. Implement authorization logic per document (who has access to which doc).
- Regular backups: backup PostgreSQL hourly, archive to S3 or remote storage. Test recovery monthly.
- 24/7 monitoring: setup Prometheus + Grafana. Alert if lag > 500ms, changelog > 500MB, connections > threshold.
- Zero-downtime upgrades: deploy new server versions in rolling fashion. Use readiness probes to avoid traffic during startup.
Alternative: federated sync (peer-to-peer)
If your use case doesn't require a central server (e.g., offline-first notes app with optional sync), Yjs supports P2P sync via WebRTC:
import { WebrtcConn } from 'y-webrtc';
const ydoc = new Y.Doc();
const ytext = ydoc.getText('shared-text');
// All P2P clients sync via WebRTC (requires signaling server)
const webrtcProvider = new WebrtcConn({
name: 'shared-doc',
doc: ydoc,
signaling: ['ws://signal-server.com'],
awareness: new Awareness(ydoc)
});
// Awareness: cursors and presence
const awareness = webrtcProvider.awareness;
awareness.setLocalState({
user: { name: 'Alice', color: '#ff0000' }
});
awareness.on('change', changes => {
// Update of another user's position
console.log('Remote user moved:', changes);
});
FAQ
Q: What is the maximum throughput of a Yjs sync server?
A: Depends on infrastructure. A single Node.js server on 4 cores (e.g., Hetzner CX21) easily handles 5000-10000 concurrent clients with average update rate. For spiky traffic, use Redis clustering + load balancer. Yjs benchmark: ~100k updates/second with small updates (<1KB).
Q: How do I archive historical documents?
A: Implement a retention policy. After N days (e.g., 90), archive the changelog to cold storage (S3 Glacier) and keep only the snapshot on hot PostgreSQL. For complete audit trail, use an event sourcing pattern: immutable log separate from Yjs changelog.
Q: Is Yjs compatible with Spring Boot backend (Java)?
A: Yjs is JavaScript-first, but you can integrate a Java backend as an independent service layer. E.g.: the Node.js/JavaScript sync server manages peer sync; the Java backend handles business logic validation, additional storage, audit logs. Pass updates from Yjs to backend via HTTP/gRPC for critical validation.
Q: Can I use Yjs without a central server (offline-only)?
A: Yes. IndexeddbPersistence alone enables a fully offline app with local persistence. Server synchronization is optional. For device-to-device sync, use P2P WebRTC (requires a signaling server for discovery, but no data storage).
Q: How reliable is Yjs in production?
A: Very. Yjs has ~10 years of development, is used by Figma (in part), Notion, and is backed by Drifting. The core library is stable; major risks are in infrastructure (Node server, PostgreSQL, Redis). Follow the best practices here and monitor 24/7.

Commenti
Caricamento commenti...
Sign in to comment