Document Size & Garbage Collection

How Y.js documents grow over time, how garbage collection compacts them, and what to expect for real-world prototyping workloads.

Document Size and Garbage Collection

Y.js documents grow with every edit — not just the current state, but the full history of changes. Garbage collection, struct merging, and IndexedDB compaction keep that growth under control automatically.

How Y.js stores changes

Every Y.Map.set(), Y.Array.push(), or Y.Text.insert() creates an internal Item struct. Y.js never discards these structs outright because they carry metadata that other clients need for conflict-free sync.

When you overwrite a map key 100 times, the document contains 100 Items for that key — one per write. The current value is the latest Item; previous Items are marked deleted.

map.set('title', 'Draft')     →  Item { id: (1,0), content: "Draft" }
map.set('title', 'Final')     →  Item { id: (1,1), content: "Final" }
                                  Item { id: (1,0), content: DELETED }

The deleted Item retains its id (client + clock) so that other peers can reference it during sync. The actual string "Draft" is garbage collected — only a compact metadata stub remains.

Garbage collection

Y.js runs garbage collection automatically after every transaction when gc: true (the default). GC replaces the content of deleted Items with a lightweight marker.

What GC does

  1. Content removal — deleted Item content becomes ContentDeleted(length), a fixed-size struct regardless of original value size.
  2. Struct merging — adjacent deleted Items from the same client collapse into a single GC struct. Ten consecutive deletions become one struct.
  3. Adjacent merge — consecutive insertions from the same client merge their content. Typing "hello" in five keystrokes produces one Item with content "hello", not five separate Items.

When GC runs

GC runs inside cleanupTransactions() at the end of every doc.transact() call. You do not need to trigger it manually.

doc.transact(() => {
  map.set('title', 'New value')   // marks old Item as deleted
})
// ← GC runs here automatically
//    old Item content replaced with ContentDeleted
//    adjacent deleted structs merged

What GC preserves

GC removes content but preserves the causal metadata — the Item's client ID, clock, and left/right origin references. This metadata is essential for sync: without it, a peer applying an update that references the deleted Item would fail to find its insertion position.

The impact of GC

Workloadgc=truegc=falseSavings
5 fields overwritten 1,000 times44 KB117 KB2.7x smaller
3 fields overwritten 10,000 times277 KB~800 KB~3x smaller

GC is always enabled in protokit. There is no reason to disable it for prototyping workloads.

IndexedDB compaction

y-indexeddb stores every Y.js update as a separate IndexedDB entry. Over time, hundreds of small entries accumulate. The library compacts them automatically.

How compaction works

  1. Every doc.transact() produces one incremental update (~30–100 bytes). This update is appended to IndexedDB immediately.
  2. When the update count reaches 500, y-indexeddb triggers compaction.
  3. Compaction calls Y.encodeStateAsUpdate(doc) — a single binary blob representing the entire document state with all merging and GC applied.
  4. The single blob replaces all 500+ previous entries in IndexedDB.
  5. The update counter resets to zero.
Update #1   ┐
Update #2   │  500 small entries in IndexedDB
...         │  (total: ~25 KB across 500 rows)
Update #500 ┘
        │
        ▼  compaction trigger
Single encoded state  ←  one IndexedDB entry (~8 KB)

What compaction achieves

Compaction reduces both storage size and read latency. Loading 500 individual IndexedDB entries on page load is slower than loading one pre-merged blob. The size reduction depends on the workload — typically 2–3x smaller because encodeStateAsUpdate() produces optimally merged structs.

MetricBefore compaction (500 updates)After compaction
IndexedDB entries5001
Total stored bytes~52 KB~18 KB
Load time on mount~50 ms~10 ms

Size benchmarks

All benchmarks use gc: true (default). Sizes represent Y.encodeStateAsUpdate(doc).byteLength — the wire format stored in IndexedDB and sent to the server.

Overwriting map keys

Simulates form fields edited repeatedly (the most common protokit workload).

UpdatesDoc sizeNotes
10327 B3 keys, negligible
1002.6 KB
1,00026 KB
10,000277 KBExtreme — 10K edits to 3 fields

Growing a collection

Simulates adding items to a CRUD list.

ItemsDoc sizePer-item overhead
506.2 KB~124 B
10012.4 KB~124 B
1,000126 KB~126 B

Task manager with rich content

100 tasks with multi-paragraph descriptions, comments, subtasks, and heavy editing over time.

PhaseDoc sizeGrowth
100 tasks created (with descriptions, comments, subtasks)208 KB—
+ 20 rounds of description rewrites216 KB+8 KB
+ 700 status changes232 KB+16 KB
+ 340 new comments added342 KB+110 KB
+ 50 title renames343 KB+1 KB
+ 20 delete/recreate cycles299 KB-45 KB (GC)

The final 299 KB document contains 266 KB of actual JSON data — Y.js overhead is only 1.1x.

Long-term daily usage

Simulates 2 years of daily task manager usage: 5 tasks created per day, 20 status changes, 10 description edits, 3 tasks archived.

TimelineDoc sizeActive tasks
1 month28 KB90
6 months125 KB390
1 year252 KB760
2 years502 KB1,490

After two full years of daily usage, the document is ~500 KB — smaller than a single photograph.

GC in action

The best way to see garbage collection work is to run the same operations on two identical documents — one with gc: true and one with gc: false — and compare sizes.

All numbers below come from actual Y.js simulations. Run the scripts yourself in packages/nuxt-protokit/scripts/ to reproduce them.

Watching GC on every overwrite

A single task with a ~500-character description is overwritten repeatedly. Both docs perform the same operations:

Overwritegc=truegc=falseGC savingsWhat GC did
1615 B1.0 KB1.7xOld description content → ContentDeleted(1) stub
2630 B1.5 KB2.5xAdjacent deleted structs merged
3579 B1.9 KB3.4xSame: strip content, merge stubs
5630 B2.9 KB4.7x
10627 B5.2 KB8.5x10 old strings retained vs 2 tiny stubs
100621 B46.7 KB76.9x100 old strings retained vs 2 tiny stubs

The gc=true doc stays under 630 bytes regardless of how many times you overwrite. The gc=false doc grows linearly because every old string is retained.

Internally, this is what GC does to each deleted Item:

Before GC: Item { id:(1,5), deleted:true, content:"## Overview\nTask 5: Dashboard..." }  ← ~500 bytes
After GC:  Item { id:(1,5), deleted:true, content:ContentDeleted(1) }                   ← 8 bytes

The Item ID stays (needed for sync) but the string content is gone.

GC on mixed CRUD operations

A more realistic workload — 10 tasks with descriptions, status changes, comments, and deletions. Every operation runs on both a gc=true and gc=false doc:

Operationgc=truegc=falseGC savings
Create 10 tasks with descriptions5.4 KB5.4 KB1.0x
+ Rename all 10 titles5.6 KB5.7 KB1.0x
+ 50 status changes (5 rounds × 10)6.0 KB6.5 KB1.1x
+ 30 description rewrites (3 rounds × 10)6.4 KB20.7 KB3.2x
+ Delete 5 tasks, add 5 new6.2 KB23.4 KB3.8x
+ Add 30 comments (3 per task)14.8 KB32.1 KB2.2x
+ 100 more description rewrites15.8 KB79.8 KB5.1x

Two patterns emerge:

  • Pure appends (creating tasks, adding comments) show no GC benefit — nothing is deleted, so there is nothing to collect.
  • Overwrites of large values (description rewrites) show the largest GC benefit — each ~500-byte string is replaced with an 8-byte stub.

After all operations: gc=true has 180 alive items + 150 deleted stubs + 48 GC structs at 15.8 KB. The gc=false doc retains all original content at 79.8 KB — GC saved 64 KB.

Multi-user stress test

Three users — Alice, Bob, and Carol — collaborate on a task manager through a central server over two simulated years. The simulation runs identical operations on parallel gc=true and gc=false document sets to measure GC savings at every checkpoint.

Workload

  • Alice creates 2 tasks per day and makes 5 edits (description rewrites, status changes, comments).
  • Bob makes 8 edits per day (status changes, reassignments, comments).
  • Carol joins at month 3 and makes 4 edits per day.
  • All three sync with the server daily.
  • 1 completed task is archived every 3 days.

Results

Daygc=truegc=falseGC savingsActive tasksAlive / Deleted / GC stubs
11.6 KB2.1 KB1.3x219 / 5 / 0
710.8 KB17.8 KB1.6x13123 / 83 / 14
3047.6 KB85.0 KB1.8x51546 / 377 / 54
90153.6 KB291.0 KB1.9x1511,793 / 1,098 / 147
180322.8 KB643.4 KB2.0x3013,745 / 2,622 / 253
365682.7 KB1.3 MB2.0x6107,935 / 5,698 / 495
5451.0 MB2.0 MB2.0x91012,066 / 8,642 / 750
7301.4 MB2.7 MB2.0x1,21816,362 / 11,688 / 990

What the numbers show

GC savings stabilize at 2.0x once the ratio of overwrites to new data reaches a steady state. GC freed 1.3 MB over the 2-year period.

Growth is driven by actual data, not history. After 2 years with 1,218 active tasks (each with descriptions, comments, and metadata), the bulk of the 1.4 MB is current content — not CRDT overhead.

All four docs converge after every sync. Server, Alice, Bob, and Carol hold identical content after the final sync round. No data loss, no divergence, despite daily concurrent edits from three clients.

IndexedDB compaction on top

After the 2-year state, simulating 500 additional edits (a busy week):

MetricValue
Base state (already in IndexedDB)1.4 MB
+ 500 incremental updates1.4 MB across 501 new entries
Total before compaction2.8 MB in 502 entries
After compaction1.4 MB in 1 entry
Storage saved1.4 MB

IndexedDB compaction eliminates all the incremental update entries, reducing the on-disk footprint back to the size of the current document state.

Sync and compaction safety

A common concern: if one client compacts its document, does that break sync with a stale client that hasn't synced in a while?

No. Compaction preserves the state vector — the mapping of (clientID → clock) that tells each peer exactly which operations the other has seen. After compaction:

  1. The stale client sends its state vector to the server.
  2. The server computes Y.encodeStateAsUpdate(doc, staleClientStateVector) — only the operations the stale client is missing.
  3. The stale client applies the diff. No data loss, no divergence.

This works because compaction does not discard causal metadata. The struct IDs remain intact even after GC replaces content with compact markers.

Client A compacts → syncs with server → makes new edits
Client B (stale for days) → syncs with server
        │
        ▼
Server sends only the diff B is missing
B applies diff → all clients converge ✅

Practical guidance

For typical protokit workloads — prototyping tools, form-heavy apps, task managers, and CRUD collections — document size stays manageable without intervention.

  • GC runs automatically after every transaction. No configuration needed. Saves 2x on overwrite-heavy workloads.
  • IndexedDB compacts every 500 updates. No configuration needed.
  • Server sync is safe after compaction. Stale clients catch up correctly.
  • Y.Map.set() for descriptions is efficient. Overwriting a string field costs only the metadata stub (~8 bytes after GC), not the full old string.
  • 2 years of 3-user daily usage produces a 1.4 MB document with 1,218 active tasks. Without GC it would be 2.7 MB.
If you use Y.Text for collaborative rich-text editing where two users type simultaneously, the document grows faster because every keystroke is a separate operation. For protokit's typical use case — single-user form fields stored as strings via Y.Map.set() — this does not apply.

When to consider manual compaction

Manual compaction is unnecessary for most applications. Consider it only if:

  • A single document exceeds 5 MB (extremely unlikely with protokit schemas).
  • Initial page load exceeds 200 ms due to IndexedDB read time.
  • You are building a server that stores Y.js state for thousands of documents and want to reduce storage costs.

Server-side compaction follows the same pattern as IndexedDB compaction:

// Server-side compaction (if ever needed)
const state = Y.encodeStateAsUpdate(doc)
const compacted = new Y.Doc()
Y.applyUpdate(compacted, state)
const compactedState = Y.encodeStateAsUpdate(compacted)
// Store compactedState — state vectors preserved, sync-safe

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.