Document Size & Garbage Collection
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
- Content removal — deleted Item content becomes
ContentDeleted(length), a fixed-size struct regardless of original value size. - Struct merging — adjacent deleted Items from the same client collapse into a single
GCstruct. Ten consecutive deletions become one struct. - 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
| Workload | gc=true | gc=false | Savings |
|---|---|---|---|
| 5 fields overwritten 1,000 times | 44 KB | 117 KB | 2.7x smaller |
| 3 fields overwritten 10,000 times | 277 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
- Every
doc.transact()produces one incremental update (~30–100 bytes). This update is appended to IndexedDB immediately. - When the update count reaches 500,
y-indexeddbtriggers compaction. - Compaction calls
Y.encodeStateAsUpdate(doc)— a single binary blob representing the entire document state with all merging and GC applied. - The single blob replaces all 500+ previous entries in IndexedDB.
- 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.
| Metric | Before compaction (500 updates) | After compaction |
|---|---|---|
| IndexedDB entries | 500 | 1 |
| 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).
| Updates | Doc size | Notes |
|---|---|---|
| 10 | 327 B | 3 keys, negligible |
| 100 | 2.6 KB | |
| 1,000 | 26 KB | |
| 10,000 | 277 KB | Extreme — 10K edits to 3 fields |
Growing a collection
Simulates adding items to a CRUD list.
| Items | Doc size | Per-item overhead |
|---|---|---|
| 50 | 6.2 KB | ~124 B |
| 100 | 12.4 KB | ~124 B |
| 1,000 | 126 KB | ~126 B |
Task manager with rich content
100 tasks with multi-paragraph descriptions, comments, subtasks, and heavy editing over time.
| Phase | Doc size | Growth |
|---|---|---|
| 100 tasks created (with descriptions, comments, subtasks) | 208 KB | — |
| + 20 rounds of description rewrites | 216 KB | +8 KB |
| + 700 status changes | 232 KB | +16 KB |
| + 340 new comments added | 342 KB | +110 KB |
| + 50 title renames | 343 KB | +1 KB |
| + 20 delete/recreate cycles | 299 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.
| Timeline | Doc size | Active tasks |
|---|---|---|
| 1 month | 28 KB | 90 |
| 6 months | 125 KB | 390 |
| 1 year | 252 KB | 760 |
| 2 years | 502 KB | 1,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:
| Overwrite | gc=true | gc=false | GC savings | What GC did |
|---|---|---|---|---|
| 1 | 615 B | 1.0 KB | 1.7x | Old description content → ContentDeleted(1) stub |
| 2 | 630 B | 1.5 KB | 2.5x | Adjacent deleted structs merged |
| 3 | 579 B | 1.9 KB | 3.4x | Same: strip content, merge stubs |
| 5 | 630 B | 2.9 KB | 4.7x | |
| 10 | 627 B | 5.2 KB | 8.5x | 10 old strings retained vs 2 tiny stubs |
| 100 | 621 B | 46.7 KB | 76.9x | 100 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:
| Operation | gc=true | gc=false | GC savings |
|---|---|---|---|
| Create 10 tasks with descriptions | 5.4 KB | 5.4 KB | 1.0x |
| + Rename all 10 titles | 5.6 KB | 5.7 KB | 1.0x |
| + 50 status changes (5 rounds × 10) | 6.0 KB | 6.5 KB | 1.1x |
| + 30 description rewrites (3 rounds × 10) | 6.4 KB | 20.7 KB | 3.2x |
| + Delete 5 tasks, add 5 new | 6.2 KB | 23.4 KB | 3.8x |
| + Add 30 comments (3 per task) | 14.8 KB | 32.1 KB | 2.2x |
| + 100 more description rewrites | 15.8 KB | 79.8 KB | 5.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
| Day | gc=true | gc=false | GC savings | Active tasks | Alive / Deleted / GC stubs |
|---|---|---|---|---|---|
| 1 | 1.6 KB | 2.1 KB | 1.3x | 2 | 19 / 5 / 0 |
| 7 | 10.8 KB | 17.8 KB | 1.6x | 13 | 123 / 83 / 14 |
| 30 | 47.6 KB | 85.0 KB | 1.8x | 51 | 546 / 377 / 54 |
| 90 | 153.6 KB | 291.0 KB | 1.9x | 151 | 1,793 / 1,098 / 147 |
| 180 | 322.8 KB | 643.4 KB | 2.0x | 301 | 3,745 / 2,622 / 253 |
| 365 | 682.7 KB | 1.3 MB | 2.0x | 610 | 7,935 / 5,698 / 495 |
| 545 | 1.0 MB | 2.0 MB | 2.0x | 910 | 12,066 / 8,642 / 750 |
| 730 | 1.4 MB | 2.7 MB | 2.0x | 1,218 | 16,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):
| Metric | Value |
|---|---|
| Base state (already in IndexedDB) | 1.4 MB |
| + 500 incremental updates | 1.4 MB across 501 new entries |
| Total before compaction | 2.8 MB in 502 entries |
| After compaction | 1.4 MB in 1 entry |
| Storage saved | 1.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:
- The stale client sends its state vector to the server.
- The server computes
Y.encodeStateAsUpdate(doc, staleClientStateVector)— only the operations the stale client is missing. - 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.
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
Corruption Recovery
How protokit detects IndexedDB corruption, queries server snapshots, and guides users through recovery without data loss.
Multi-tenant Isolation
How to scope Y.js documents per user, tenant, or workspace — and how to clean up IndexedDB on logout.