Persisting CRDT updates on the server #
Your collaborative editor works while people are connected, and then the server restarts and every document opens empty — or opens correctly but takes eight seconds, because loading replays two million tiny updates recorded since the document was created. Persistence for CRDT documents looks trivial, since the library can serialize the whole document to a byte array. In practice there are two very different operations to support: recording each small update durably as it arrives, and loading a document quickly. The first wants an append-only log; the second wants a compact snapshot. A production design uses both, with compaction bridging them.
Root cause #
A CRDT document is the merge of every update ever applied to it. There are two obvious ways to store it, and each fails at scale on its own.
Snapshot on every update — call Y.encodeStateAsUpdate(doc) after each edit and overwrite a row — writes the entire document for every keystroke. A 200 KB document edited by five people typing produces tens of megabytes of writes per minute, and a crash between the edit and the write loses it.
Log every update and replay on load is cheap to write and crash-safe, but load time grows with the document’s entire history. Documents that live for months accumulate millions of entries, and every open replays all of them.
The combination — append each update to a log, and periodically fold the log into a snapshot and truncate it — gives cheap writes, bounded load time, and no lost edits, as long as compaction is atomic with respect to new appends.
Resolution #
Store two things per document: a snapshot (the full encoded state as of some log position) and an update log of everything appended since. On load, apply the snapshot, then the log tail. After enough updates accumulate, compact: build a snapshot from the current state, then atomically replace the snapshot and delete the log entries it covers. With PostgreSQL:
import * as Y from 'yjs';
import type { Pool } from 'pg';
const COMPACT_AFTER_UPDATES = 500; // fold the log once it has this many entries
const COMPACT_AFTER_BYTES = 1 << 20; // …or this many bytes, whichever comes first
/*
CREATE TABLE doc_snapshots (doc_id text PRIMARY KEY, state bytea NOT NULL, upto bigint NOT NULL);
CREATE TABLE doc_updates (doc_id text NOT NULL, id bigserial, update bytea NOT NULL,
PRIMARY KEY (doc_id, id));
*/
export async function loadDoc(db: Pool, docId: string, doc: Y.Doc) {
const snap = await db.query('SELECT state, upto FROM doc_snapshots WHERE doc_id = $1', [docId]);
const upto: string = snap.rows[0]?.upto ?? '0';
const tail = await db.query(
'SELECT update FROM doc_updates WHERE doc_id = $1 AND id > $2 ORDER BY id', [docId, upto]);
Y.transact(doc, () => {
if (snap.rows[0]) Y.applyUpdate(doc, snap.rows[0].state);
for (const r of tail.rows) Y.applyUpdate(doc, r.update); // order doesn't matter, but is cheap
}, 'load');
}
// Called from doc.on('update') — one small insert per update, acknowledged before we move on.
export async function persistUpdate(db: Pool, docId: string, update: Uint8Array) {
await db.query('INSERT INTO doc_updates (doc_id, update) VALUES ($1, $2)', [docId, update]);
}
export async function maybeCompact(db: Pool, docId: string) {
const stats = await db.query(
'SELECT count(*)::int AS n, coalesce(sum(length(update)),0)::int AS bytes, max(id) AS maxid ' +
'FROM doc_updates WHERE doc_id = $1', [docId]);
const { n, bytes, maxid } = stats.rows[0];
if (n < COMPACT_AFTER_UPDATES && bytes < COMPACT_AFTER_BYTES) return;
const client = await db.connect();
try {
await client.query('BEGIN');
// Lock this document's snapshot row so two compactions can't interleave.
await client.query('SELECT 1 FROM doc_snapshots WHERE doc_id = $1 FOR UPDATE', [docId]);
// Rebuild from storage up to maxid only — updates appended meanwhile stay in the log.
const doc = new Y.Doc();
const snap = await client.query('SELECT state FROM doc_snapshots WHERE doc_id = $1', [docId]);
const upd = await client.query(
'SELECT update FROM doc_updates WHERE doc_id = $1 AND id <= $2 ORDER BY id', [docId, maxid]);
if (snap.rows[0]) Y.applyUpdate(doc, snap.rows[0].state);
for (const r of upd.rows) Y.applyUpdate(doc, r.update);
const state = Y.encodeStateAsUpdate(doc);
await client.query(
`INSERT INTO doc_snapshots (doc_id, state, upto) VALUES ($1, $2, $3)
ON CONFLICT (doc_id) DO UPDATE SET state = EXCLUDED.state, upto = EXCLUDED.upto`,
[docId, state, maxid]);
await client.query('DELETE FROM doc_updates WHERE doc_id = $1 AND id <= $2', [docId, maxid]);
await client.query('COMMIT');
doc.destroy();
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
Compaction rebuilds from storage, bounded by maxid, rather than encoding the live in-memory document. That matters: the live document may contain updates that have been applied in memory but whose inserts have not committed, and a snapshot built from it could claim a log position it does not really cover. Rebuilding from committed rows keeps the snapshot and the upto marker consistent, and updates appended during compaction simply remain in the log for the next round.
Durability ordering is the other rule. Persist an update before acknowledging it, or at least before the server could lose it; with the relay design in syncing Yjs documents over WebSockets, a crash after relaying but before persisting would leave the edit on other clients’ replicas, and it would come back through their next sync. CRDTs are forgiving here — any replica that has an update can restore it — but relying on clients as backup is not a storage strategy.
Edge cases #
Write amplification from keystrokes. One row per keystroke is fine for small teams and noisy at scale. Batch updates in memory for a short window (100–500 ms) and write the merged update (Y.mergeUpdates) as one row, accepting that the window’s edits live only in memory and on clients until then.
Garbage collection and history. Yjs garbage-collects deleted content by default, which keeps snapshots small but discards the history needed for version browsing. If you need “show changes since yesterday”, keep periodic named snapshots with GC disabled, or store versions separately.
Documents that never close. A document open around the clock never unloads, so compaction must run on a schedule or update count, not only on unload.
Verification #
Crash-test the design. Open a document, type continuously, and kill the server process with SIGKILL mid-typing; restart and reload. Everything persisted before the kill must be present, and anything newer must return through clients’ resync. Then compare load times before and after compaction on a document with a long history — the numbers should look like the chart above.
-- Documents whose logs are overdue for compaction.
SELECT doc_id, count(*) AS pending, pg_size_pretty(sum(length(update))) AS bytes
FROM doc_updates GROUP BY doc_id ORDER BY count(*) DESC LIMIT 20;
Operational checklist #
FAQ #
How should I store Yjs documents in a database? #
Keep a snapshot of the encoded state and an append-only table of updates since that snapshot. Load by applying both, and periodically fold the updates into a new snapshot.
Can I just save the whole document on every change? #
For small documents with few editors, yes. It becomes expensive as documents grow, because every keystroke rewrites the entire document, and it loses the edit if the process dies before the write.
Does update order matter when loading? #
Not for correctness — CRDT updates commute — but applying the snapshot first and then updates in log order is efficient and makes debugging easier.
Is Redis a good store for CRDT updates? #
Redis Streams work well as the short-term log, with a database or object storage holding snapshots. Persist snapshots somewhere durable; Redis alone is a cache unless configured for persistence.
Related #
- Syncing Yjs Documents over WebSockets — where
loadDocandpersistUpdateare called. - CRDT vs Operational Transform for Real-Time Editing — why history grows.
- Outbox Pattern for WebSocket Events — durable writes and delivery for non-CRDT data.
- Redis Streams vs Pub/Sub for WebSocket Fan-Out — a log that doubles as a relay.
Back to Collaborative Editing with CRDTs.