Skip to main content

Yjs per produzione: sync server architecture

Article from the Local-First Software Architecture series. Production-ready sync server design with Yjs, WebSocket, and database persistence.

What do you think about this article?

Yjs per produzione: sync server architecture
Local-First SoftwarePhotos:SiddePexels

At a Glance

Article from the Local-First Software Architecture series. Production-ready sync server design with Yjs, WebSocket, and database persistence.

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:

  1. Save the state vector and binary update to a changelog table
  2. Periodically (every N updates or every T seconds), generate a snapshot by compressing the changelog
  3. 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:

  1. Client A connected to Server 1 sends an update
  2. Server 1 applies the update to its local ydoc, persists to PostgreSQL, and publishes to Redis
  3. 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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects