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.

Illustrative document load time by history length Replaying a full log grows from milliseconds to forty seconds as history grows to two million updates, while loading a snapshot, with or without a short tail of recent updates, stays in the tens of milliseconds. Illustrative document load time by history length same 200 KB document, different storage layouts log only (replay all) snapshot only snapshot + short log 0 s 10 s 20 s 30 s 40 s 1k updates 100k updates 2M updates
Snapshots bound load time; the log keeps every edit durable in between.

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.

Compaction without losing concurrent edits The relay appends updates 901 to 1400; the compactor reads up to 1400 while update 1401 is appended; it writes a snapshot marked upto 1400 and deletes entries up to 1400, leaving 1401 in the log. Compaction without losing concurrent edits Relay doc_updates Compactor doc_snapshots append ids 901–1400 read up to id 1400 append id 1401 (during compaction) snapshot, upto=1400 delete ids ≤ 1400 id 1401 remains for next round The upto marker, not wall-clock time, defines what the snapshot covers
Bound compaction by log position so concurrent appends are never swallowed.

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;
Storage layout per document Each document stores one snapshot row with an upto marker, a short update log tail, optional named version snapshots, and is loaded into memory as snapshot plus tail while active. Storage layout per document Snapshot row full encoded state + upto log position 1 row Update log tail updates appended since upto, in id order ≤ 500 rows Named versions (optional) periodic snapshots with history for version browsing N rows In-memory doc loaded = snapshot + tail; unloaded when idle per active doc Load cost is bounded by snapshot size plus at most one compaction interval of updates
One snapshot, one short log, and the memory copy built from them.

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.

Back to Collaborative Editing with CRDTs.