Salta al contenuto principale

Yjs per produzione: sync server architecture

Articolo della serie Local-First Software Architecture. Pubblicazione prevista 20 July 2027.

Cosa ne pensi di questo articolo?

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

In Breve

Articolo della serie Local-First Software Architecture. Pubblicazione prevista 20 July 2027.

Risposta rapida: Yjs è un motore CRDT (Conflict-free Replicated Data Type) che sincronizza automaticamente lo stato tra più client senza conflitti di merge manuale. In produzione, la sync server funge da coordinatore centrale, persistendo gli aggiornamenti su database e broadcasting i cambiamenti a tutti i client connessi via WebSocket. L'architettura corretta richiede: (1) un server WebSocket robusto con connection pooling, (2) persistenza durable su PostgreSQL/Redis, (3) metriche di monitoring per rilevare lag di sincronizzazione, (4) gestione dei crash via update log immutabile. Questo articolo illustra la progettazione e l'implementazione step-by-step di una sync server production-ready con Yjs, inclusi provider, scalability e debugging.

Fondamenti: cosa sono i CRDT e perché Yjs

Un CRDT (Conflict-free Replicated Data Type) è una struttura dati che garantisce convergenza automatica dello stato tra replica indipendenti, anche in caso di offline, latenza o ordine arbitrario dei messaggi. A differenza dei sistemi operativi tradizionali (OT — Operational Transform) che richiedono un server autoritario, i CRDT funzionano peer-to-peer e sono ideali per architetture local-first.

Yjs è la libreria CRDT più matura per JavaScript: supporta array, map, text (con cursori), la sincronizzazione incrementale, la persistenza locale via IndexedDB e provider pluggable (WebSocket, WebRTC, IPFS). La serializzazione binaria Yjs è ~10x più compatta di JSON, riducendo significativamente il traffico di rete.

In una architettura local-first, ogni client mantiene una copia completa dello stato localmente (IndexedDB), synca solo i delta via WebSocket, e rimane pienamente funzionante offline. Quando il network ritorna, la sync server riconcilia tutti i cambiamenti concorrenti senza conflitti.

Architettura della sync server

Una sync server Yjs production-ready ha tre responsabilità critiche:

  • Coordinamento: traccia le connessioni WebSocket, invia aggiornamenti a tutti i client interessati, gestisce join/leave di nuovi client
  • Persistenza durabile: salva ogni aggiornamento Yjs su un database (PostgreSQL) per survival ai crash del server, e un changelog immutabile per recovery
  • Scaling orizzontale: supporta multiple istanze di server dietro un load balancer, sincronizzando lo stato tramite Redis pub/sub

La topologia consigliata è:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   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)
    └──────────┘ └───────┘ └────────┘

Implementazione: Server WebSocket con y-websocket-server

Il provider standard per Yjs è y-websocket-server, che gestisce il broadcasting di aggiornamenti. Installazione:

npm install yjs y-websocket y-websocket-server ws

Un server minimale 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 });

// Mappa globale dei documenti Yjs (memoria)
// In produzione, caricare da PostgreSQL al primo accesso
const docs = new Map();

// Funzione per ottenere o creare un documento
function getDoc(docName) {
  if (!docs.has(docName)) {
    const ydoc = new Y.Doc();
    docs.set(docName, ydoc);
    // Carica snapshot da PostgreSQL (pseudo-codice)
    // 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) => {
  // Estrai docName dall'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');
});

Persistenza: salvare aggiornamenti su PostgreSQL

Per durabilità, ogni aggiornamento Yjs va salvato su PostgreSQL. La strategia è:

  1. Salva il vettore di stato (state vector) e l'update binario in una tabella changelog
  2. Periodicamente (ogni N aggiornamenti o ogni T secondi), genera una snapshot comprimendo il changelog
  3. Al crash, ricarica la snapshot + replay i log successivi
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: 'postgresql://user:pass@localhost/yjs_sync'
});

// Schema SQL (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()
// );

// Quando ricevi un aggiornamento 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();
  }
}

// Genera snapshot periodicamente (es. ogni 500 aggiornamenti):
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();
    }
  }
}

Sincronizzazione tra più server (Redis pub/sub)

Se hai N server dietro un load balancer, devi sincronizzare gli aggiornamenti tra server via Redis pub/sub. Così:

  1. Client A connesso a Server 1 invia update
  2. Server 1 applica l'update al suo ydoc locale, persiste su PostgreSQL, e pubblica su Redis
  3. Server 2 e 3 subscribiti al canale Redis ricevono l'update, lo applicano ai loro ydoc, e lo broadcastano ai client connessi
import redis from 'redis';

const redisPub = redis.createClient();
const redisSub = redis.createClient();

// Quando un aggiornamento arriva via WebSocket su Server 1:
function handleUpdate(docName, update, stateVector, ws) {
  const ydoc = getDoc(docName);
  Y.applyUpdate(ydoc, update);

  // Salva su PostgreSQL
  persistUpdate(docName, update, stateVector);

  // Pubblica su Redis per gli altri server
  redisPub.publish(`yjs:${docName}`, JSON.stringify({
    type: 'update',
    update: update.toString('base64'),
    stateVector: stateVector.toString('base64')
  }));

  // Broadcast ai client connessi a questo server
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(update);
    }
  });
}

// Subscribe su Redis per ricevere aggiornamenti da altri server:
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 ai client di questo server
    wss.clients.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(update);
      }
    });
  }
});

Client-side: IndexeddbPersistence e provider WebSocket

Sul lato client (Angular/React), la libreria fornisce due provider essenziali:

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';

// Crea il documento Yjs
const ydoc = new Y.Doc();

// Persisti localmente su IndexedDB
const indexeddbProvider = new IndexeddbPersistence('doc-name', ydoc);

// Sincronizza con il server via WebSocket
const websocketProvider = new WebsocketProvider(
  'wss://sync.example.com',
  'doc-name',
  ydoc
);

// Observable dello stato di sync
websocketProvider.on('connection-close', () => {
  console.log('Disconnected, working offline');
});

websocketProvider.on('connection-open', () => {
  console.log('Connected to sync server');
});

// Rifletti gli aggiornamenti nel DOM (es. con Svelte, Preact o Angular signals)
const ytext = ydoc.getText('shared-text');
ytext.observe(event => {
  console.log('Text updated:', ytext.toString());
});

export { ydoc, ytext, websocketProvider };

Gestione della connessione e offline-first

Yjs abilita nativamente l'offline-first: i client rimangono funzionali offline grazie a IndexedDB. Quando la rete ritorna, la sincronizzazione riparte automaticamente. La chiave è non bloccare l'interfaccia durante la sync:

// In Angular, usa un signal reattivo:
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');
    });
  }
}

// Nel template:
// <div *ngIf="docService.syncStatus() === 'syncing'">Syncing...</div>

Monitoring e metriche

Per monitorare una sync server in produzione, traccia:

  • Lag di sincronizzazione: differenza tra il timestamp dell'update lato client e quello applicato al server. Target: <100ms in condizioni normali.
  • Dimensione del changelog: cresce con il numero di aggiornamenti. Se cresce indefinitamente, aumenta la frequenza di snapshot.
  • Connessioni WebSocket attive: numero di client connessi in tempo reale.
  • Errori di persistenza: fallimenti di query PostgreSQL (retry logic, dead letter queue).
  • Spazio disco: il changelog binario occupa spazio; monitora e archiva i dati antichi.
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']
});

// Registra il lag quando un aggiornamento viene applicato:
function handleUpdate(docName, update, clientTimestamp) {
  const serverTimestamp = Date.now();
  syncLagHistogram.observe(serverTimestamp - clientTimestamp);
  // ... rest of handler
}

Troubleshooting: debug e recovery

Problemi comuni e soluzioni:

Client e server divergono

Causa: aggiornamenti persi durante una disconnessione. Soluzione: Il client IndexeddbPersistence memorizza tutti gli aggiornamenti locali. Quando la rete ritorna, il WebsocketProvider calcola il diff (state vector) tra il server e il client, e sincronizza solo i cambiamenti mancanti.

Per verificare manualmente: in browser DevTools, apri IndexedDB > yjs-sync-storage > updates e controlla che gli ultimi update siano presenti.

Memoria del server cresce indefinitamente

Causa: il server tiene i documenti Yjs in memoria senza mai liberarli. Soluzione: implementa un garbage collector che scarica documenti non acceduti per X minuti:

const DOC_TTL_MS = 30 * 60 * 1000; // 30 minuti
const lastAccessedTime = new Map();

function getDoc(docName) {
  lastAccessedTime.set(docName, Date.now());
  // ...
}

// Cleanup periodico:
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); // Ogni 5 minuti

Conflitti di merge (o divergenza tra client)

CRDT by design evita conflitti di merge. Se noti stato divergente tra client, la causa è quasi sempre:

  • Uno dei client non ha ricevuto un aggiornamento (check WebSocket logs)
  • La snapshot su PostgreSQL è corrotta (richiede reset manuale)
  • Clock skew tra server (sincronizza i clock via NTP)

Perdita di aggiornamenti al crash del server

Soluzione: salva gli aggiornamenti su PostgreSQL PRIMA di applicarli al ydoc in memoria. Usa una transaction atomica:

async function handleUpdateAtomic(docName, update, stateVector) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // 1. Salva l'update
    await client.query(
      'INSERT INTO ydocs_updates (doc_name, state_vector, update_data) VALUES ($1, $2, $3)',
      [docName, stateVector, update]
    );

    // 2. Applica al ydoc locale
    const ydoc = getDoc(docName);
    Y.applyUpdate(ydoc, update);

    // 3. Commit
    await client.query('COMMIT');

    // 4. Solo dopo il commit, broadcast a Redis e client
    broadcastUpdate(docName, update);
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

Best practices per la produzione

  • TLS/WSS obbligatorio: sempre WebSocket sicuro (wss://) in produzione. Usa certificati Let's Encrypt su Nginx/reverse proxy.
  • Connection pooling: usa librerie come pg per gestire le connessioni a PostgreSQL. Default 20 pool size, 5 secondi idle timeout.
  • Rate limiting: limita gli aggiornamenti per client (es. max 10 update/secondo). Previene flooding e abuse.
  • Autenticazione: verifica JWT sui WebSocket upgrade. Implementa logic di autorizzazione per documento (chi ha accesso a quale doc).
  • Backup regolari: backup PostgreSQL ogni ora, archivia su S3 o storage remoto. Testa recovery monthly.
  • Monitoring 24/7: setup Prometheus + Grafana. Alert se lag > 500ms, changelog > 500MB, connessioni > threshold.
  • Upgrade zero-downtime: deploy nuove versioni server in rolling fashion. Usa readiness probe per evitare traffic durante startup.

Alternativa: federated sync (peer-to-peer)

Se il tuo caso d'uso non richiede un server centrale (es. note app offline-first con sync opzionale), Yjs supporta sync P2P via WebRTC:

import { WebrtcConn } from 'y-webrtc';

const ydoc = new Y.Doc();
const ytext = ydoc.getText('shared-text');

// Tutti i client P2P si sincronizzano via WebRTC (requires signaling server)
const webrtcProvider = new WebrtcConn({
  name: 'shared-doc',
  doc: ydoc,
  signaling: ['ws://signal-server.com'],
  awareness: new Awareness(ydoc)
});

// Awareness: cursori e presenze
const awareness = webrtcProvider.awareness;
awareness.setLocalState({
  user: { name: 'Alice', color: '#ff0000' }
});

awareness.on('change', changes => {
  // Aggiornamento della posizione di un altro utente
  console.log('Remote user moved:', changes);
});

FAQ

D: Qual è il throughput massimo di una sync server Yjs?

R: Dipende dall'infrastruttura. Un singolo server Node.js su 4 core (es. Hetzner CX21) gestisce facilmente 5000-10000 client concurrent con update rate medio. Se fai spiky traffic, usa clustering Redis + load balancer. Benchmark pubblicato da Yjs: ~100k update/secondo con update piccoli (<1KB).

D: Come gestisco l'archiviazione di documenti storici?

R: Implementa una policy di retention. Dopo N giorni (es. 90), archivia il changelog su storage freddo (S3 Glacier) e mantieni solo la snapshot su PostgreSQL hot. Per audit trail completo, usa un event sourcing pattern: log immutabile separato dal changelog Yjs.

D: Yjs è compatibile con Spring Boot backend (Java)?

R: Yjs è JavaScript-first, ma puoi integrare il backend Java come service layer indipendente. Es.: il sync server Node.js/JavaScript gestisce la sincronizzazione peer dei client; il backend Java gestisce validazione di business logic, storage aggiuntivo, audit log. Passa aggiornamenti da Yjs al backend via HTTP/gRPC per validazione critica.

D: Posso usare Yjs senza un server centrale (offline-only)?

R: Sì. IndexeddbPersistence da solo consente un app completamente offline con persistenza locale. La sincronizzazione server è opzionale. Se vuoi sync tra dispositivi, usa WebRTC P2P (richiede un signaling server per discovery, ma no data storage).

D: Quanto è affidabile Yjs in produzione?

R: Molto. Yjs ha ~10 anni di sviluppo, è usato da Figma (in parte), Notion, ed è supportato da Drifting. La libreria core è stabile; i rischi maggiori sono in infrastruttura (server Node, PostgreSQL, Redis). Segui le best practice qui sopra e monitora 24/7.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti