Engineering7 Min Lesezeit

Y.js für Real-Time-Collaboration: Was wir gelernt haben

CRDTs sind kein Allheilmittel, aber für unsere Block-Editor-Architektur die richtige Antwort. Ein ehrlicher Bericht über WebSocket-Strategy, Snapshot-Frequenz und die Stellen, an denen wir uns blutige Nasen geholt haben.

Sebastian ·

Real-Time-Collaboration ist eines dieser Features, die im Pitch trivial klingen und in der Implementierung sechs Monate verschlingen. Wir hatten den Vorteil, dass wir mit Y.js auf eine ausgereifte CRDT-Library aufsetzen konnten, statt das Operational-Transform-Wheel neu zu erfinden. Trotzdem gab es genug Stolpersteine, dass dieser Artikel notwendig wurde.

Warum CRDT statt OT

Operational-Transform ist der ältere Ansatz, und Google Docs läuft seit über einem Jahrzehnt darauf. Das Problem mit OT ist, dass es einen autoritären Server braucht, der jede Operation gegen den aktuellen Document-State transformiert. Sobald du Offline-Editing willst — und das wollten wir — wird OT extrem schwer korrekt zu implementieren. CRDTs lösen das fundamental anders: Jeder Edit ist eine kommutative Operation, die in beliebiger Reihenfolge angewendet werden kann. Konflikt-Resolution passiert deterministisch über Vector-Clocks und Lamport-Timestamps.

Y.js implementiert einen YATA-basierten CRDT. Konkret heißt das: Jedes Zeichen im Dokument hat eine eindeutige ID (Client-ID + Counter), und Insertions referenzieren ihren Vorgänger und Nachfolger. Wenn zwei User gleichzeitig an derselben Stelle schreiben, sortiert Y.js sie deterministisch nach Client-ID. Das ist nicht das, was ein menschlicher Editor wählen würde, aber es ist konsistent über alle Clients und nicht-blockierend.

Die WebSocket-Strategy

Wir haben drei Iterationen gebraucht, um die richtige Sync-Strategie zu finden. Iteration eins war naiv: Ein WebSocket pro Document, Y.js-Updates direkt durchgereicht. Das funktionierte gut bis zu fünf parallelen Editoren pro Doc und brach dann zusammen, weil jeder Client jedes Update von jedem anderen Client erhielt. Die Update-Größe explodierte exponentiell mit der Teilnehmerzahl.

Iteration zwei führte einen Y.js-Server als Mediator ein. Der Server hält die kanonische State-Representation und sendet Updates nur an Clients, deren letzter bekannter State älter ist. Das skalierte besser, aber wir hatten ein Problem mit dem initial Sync: Neue Clients mussten den kompletten History-Stream replayen, was bei langlebigen Dokumenten Sekunden dauerte.

Iteration drei — unsere aktuelle Architektur — kombiniert State-Vectors mit periodischen Snapshots. Wenn ein Client connectet, sendet er seinen aktuellen State-Vector. Der Server berechnet die Diff und sendet entweder ein Delta-Update oder, wenn der Client weit hinterherhängt, einen vollständigen Snapshot plus die seither aufgelaufenen Updates. Diese Hybrid-Strategy schafft Sub-Sekunden-Initial-Loads selbst für tausende-Edit-große Dokumente.

Persistence: wie oft snapshotten?

Y.js-Documents wachsen über Zeit. Jeder Edit produziert einen Update-Record, der in der Document-History bleibt. Ohne Compaction wird die Document-Größe linear mit der Edit-Anzahl. Wir snapshotten alle 1.000 Updates oder alle 24 Stunden — was zuerst kommt. Das ist ein Trade-off: Mehr Snapshots heißt schnellere Loads, aber höhere Disk-Usage. Weniger Snapshots heißt das Gegenteil.

Wir speichern jeden Snapshot als binary Blob in SQLite, plus die seither aufgelaufenen Updates separat. Beim Restore lädt der Server zuerst den jüngsten Snapshot und appliziert dann die Update-Tail. In der Praxis sind das typischerweise 50-200 KB Snapshot plus 5-50 KB Update-Stream.

Das größte Problem: Awareness

Awareness sind die ephemeren Daten wie Cursor-Position, User-Name und User-Color, die zeigen, wer wo im Dokument gerade aktiv ist. Y.js hat ein eigenes Awareness-Protokoll, das vom Document-State getrennt ist. Das klingt erstmal sauber, aber wir hatten in Production ein Problem: Awareness-Updates kommen mit jedem Cursor-Move, also potenziell 60 mal pro Sekunde pro User. Bei zehn aktiven Editoren explodiert die Update-Frequenz.

Unsere Lösung war ein Throttle auf Server-Seite: Awareness-Updates werden auf maximal 10 Hz pro User gedrosselt. Das fühlt sich für menschliche User immer noch live an, reduziert aber die Bandbreite um eine Größenordnung. Y.js hat das nicht built-in, wir mussten es selbst bauen.

Was wir nicht empfehlen

Y.js für strukturierte Daten wie Tabellen-Zellen oder Form-Inputs. Wir haben das versucht und sind gescheitert. CRDTs eignen sich gut für text-artige Strukturen, aber wenn du atomare Field-Level-Updates willst mit Validation-Rules, ist ein traditionelles Last-Write-Wins-Modell mit Conflict-Detection einfacher und korrekter. Wir nutzen Y.js für den Block-Editor und den Whiteboard, und Last-Write-Wins für strukturierte Settings.

Wenn du Real-Time bauen willst und glaubst, du brauchst CRDT: Lies das Y.js-Paper, baue einen Prototyp, miss die Bandbreite unter realistischer Last. Erst dann committe.