Obsidian LiveSync's CouchDB grew to 20× the vault — finding and deleting orphaned chunks
Environment: CouchDB 3.3 (Docker) · Obsidian Self-hosted LiveSync 0.25.x (at the time of the cleanup, 2026-07) · 416 markdown files (2.7MiB)
TL;DR
LiveSync splits notes into chunks stored as leaf documents, and nothing routinely removes a chunk once no note references it. Compaction won't help either — an orphaned chunk isn't a superseded revision, it's a live document at its current revision, which is exactly what compaction leaves alone.
(The plugin does ship a manual cleanup command — beta, off by default, and its current form came out in a release after this cleanup. See the "Bonus" section below.)
- Get the list of chunk documents (ids starting with
h:) and the union of every note'schildren. - Confirm that no chunk referenced by a live note is missing (dangling) before touching anything. If any are, stop.
- Delete only the unreferenced chunks via
_bulk_docswith_deleted: true, then_compact.
The cleanup on record removed 7,881 chunks and took the database from 43.6MB to 10.4MB. Three weeks later there were 5,739 orphans again. This is periodic housekeeping, not a bug you fix once.
The symptom: 2.7MiB of notes, a 55.9MiB database
The detection queries and the numbers below come from running them again on 2026-08-11; I did not run the deletion or the compaction then — that part is reconstructed from the operational records of 2026-07-20. The background for the whole series is in Self-hosted Obsidian instead of Notion.
The database info endpoint makes the scale obvious:
curl -s -u "$CDB_USER:$CDB_PASS" http://127.0.0.1:5984/obsidiandb{"sizes":{"file":58646974,"external":24209026,"active":31718134},
"doc_del_count":7872,"doc_count":27101, …}55.9MiB on disk, 27,101 documents. At the same time the vault held 416 markdown files totaling 2.7MiB — about 65 CouchDB documents per markdown file, and roughly 20× the actual content by size.
This isn't only an aesthetic problem. That document count is what a new device has to download on its first sync, and in the incident where that first fetch kept failing at a 60-second cutoff, the more documents there were, the more reliably it hit that cutoff. Shrinking the database was part of making that survivable.
The misdiagnosis: assuming compaction would reclaim it
My first move was to reach for CouchDB compaction. CouchDB keeps superseded revisions around when a document is updated, and compaction strips their bodies. So, I assumed, it should clear this up too.
That was wrong, and the call was mine. Compaction isn't aimed at this kind of bloat. An orphaned chunk isn't an old revision pushed aside by a newer one — it's a perfectly ordinary document sitting at its own current revision. Nothing references it, but CouchDB has no reason to care. "Referenced" is an application-level concept (LiveSync's), not a CouchDB one.
The oversized plugin binary chunks I found around the same time were real, but a separate cause — that story is in the "Side cleanup" section of the #953 post. Even after clearing those, the document count was still five figures.
Root cause: unreferenced chunks are never cleared automatically
LiveSync splits note content into leaf documents with ids beginning h:, and the note document holds the list of pieces in a children array:
{"_id":"0_dashboard/dashboard.md",
"children":["h:+2gtuzj0s2h8vd","h:+1x638v4ra5qon", …]}Chunks are addressed by content, so several notes can share one. That part is visible in the documentation and in the data; the inference that follows is mine. It makes deletion awkward at the point of edit: when a note changes, new chunks appear, and the old ones can't simply be removed because something else might still be using them. Same when a note is deleted.
The trouble is that "clean it up later" never arrives. Here's what that looks like in this database right now:
- leaf chunks: 26,325
- non-leaf documents not marked deleted: 472 (plus 304 that are marked deleted)
- chunks referenced by nothing at all: 5,739 — 21.8% of all chunks
The 416 markdown files on disk and the 472 here are counting different things: this query counts non-leaf documents, which can include non-markdown files and LiveSync's own bookkeeping. I didn't break the difference down.
The fix: build the reference graph, delete only what nothing points at
Step 1 — pull the note children and the chunk list
Fetch _id and children from note documents (everything that isn't a leaf):
curl -s -u "$CDB_USER:$CDB_PASS" -H "Content-Type: application/json" \
-X POST http://127.0.0.1:5984/obsidiandb/_find \
-d '{"selector":{"type":{"$ne":"leaf"}},
"fields":["_id","children","deleted"],"limit":100000}' > notes.jsonThen walk the h: key range in _all_docs:
curl -s -u "$CDB_USER:$CDB_PASS" -H "Content-Type: application/json" \
-X POST http://127.0.0.1:5984/obsidiandb/_all_docs \
-d '{"start_key":"h:","end_key":"h;"}' > leaves.jsonPass start_key/end_key in the POST body rather than in the query string. Encoding the JSON-quoted keys into a URL is easy to get subtly wrong, and what you get back is {"error":"bad_request","reason":"invalid UTF-8 JSON"}.
Step 2 — intersect, and check the safety condition first
import json
notes = json.load(open('notes.json'))['docs']
leaves = json.load(open('leaves.json'))['rows']
leaf_ids = {r['id'] for r in leaves}
live_ref, del_ref = set(), set()
for d in notes:
(del_ref if d.get('deleted') else live_ref).update(d.get('children') or [])
print("chunks:", len(leaf_ids))
print("orphans (referenced by nothing):", len(leaf_ids - live_ref - del_ref))
print("dangling (live notes point at a missing chunk):", len(live_ref - leaf_ids))Only proceed if dangling is zero. A non-zero value means some note is already unrecoverable, and deleting more chunks on top of that destroys your ability to work out why. The 2026-08-11 run:
chunks: 26325
orphans (referenced by nothing): 5739
dangling (live notes point at a missing chunk): 0
For the record, 795 chunks referenced by deleted notes were already missing. When and how they disappeared is something I couldn't establish — my guess is that they were casualties of the earlier cleanup, but the records don't say which definition of "orphan" that cleanup used, so I can't say that for sure.
Either way it points at the same decision you have to make here: what you delete stops being restorable. Chunks nothing points at can only ever be reached through a superseded revision — and only one newer than the last compaction at that — and once they're gone that revision can't be reassembled. That's why I defined an orphan narrowly, as referenced by no document at all, including the ones marked deleted — chunks still held by a deleted note survive this procedure, so this procedure doesn't take away whatever chance of restoring those notes is left (the 795 above are a reminder that for some of them, it already is gone). Widening the definition to include them would raise 5,739 to 6,187 and take that ability away too.
Step 3 — stop sync, back up, delete
The order matters here.
- Stop the bridge and any syncing clients. New chunks arriving mid-calculation make the reference graph you just built stale.
- Snapshot the CouchDB volume. Per the record, I took a
couchdb-vol-precleanup-*.tar.gzsnapshot first. - Delete the orphans through
_bulk_docs. You need each document's_idand_rev(value.revfrom the_all_docsresponse):
curl -s -u "$CDB_USER:$CDB_PASS" -H "Content-Type: application/json" \
-X POST http://127.0.0.1:5984/obsidiandb/_bulk_docs \
-d '{"docs":[{"_id":"h:+…","_rev":"1-…","_deleted":true}, …]}'Batch it in the hundreds or low thousands rather than sending everything at once.
- Compact last:
curl -s -u "$CDB_USER:$CDB_PASS" -X POST http://127.0.0.1:5984/obsidiandb/_compact \
-H "Content-Type: application/json"Compaction goes at the end, not the start. Its job here is to strip the bodies left behind by step 3, so running it first accomplishes exactly nothing.
Verification (recorded right after the 2026-07-20 cleanup)
Every number here is from that day, so it won't line up with the 2026-08-11 measurements above.
- dangling: 0 — no chunk belonging to a live note was removed.
- 326 live notes intact — that's what the check recorded that day (the record doesn't say what it counted).
- 43.6MB → 10.4MB — 7,881 chunks (28.5MB) deleted plus compaction.
One number doesn't reconcile across the records. The same day, clearing the oversized plugin chunks was written down as 77MB → 37MB — but the starting point recorded a few hours later, for the orphan cleanup, is 43.6MB. My guess is that other devices reconnected and replication grew it back in between, but nothing was captured at the time to settle it. The effect of the cleanup itself (7,881 chunks removed, ending at 10.4MB) comes from measurements taken on the same side of that gap.
Bonus: upstream has a manual GC command
One thing I checked while writing this up: upstream LiveSync has a maintenance command called Garbage Collection V3 (Maintenance section of the settings docs, CouchDB only).
Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
That is the same job I did by hand above. What matters is the shape of it:
- It's a manual command, not something that runs on its own. You press it. So the observation in this post — that orphans accumulate — still holds.
- It's beta and disabled by default, and the docs come with conditions: the vault, the local database and the remote all have to be healthy, and every relevant device must have synchronized before you run it.
- The warning, verbatim: "It can make an ordinary superseded file revision unreadable when no live state still needs its chunks." That's giving up the ability to restore old revisions — the same trade-off as "what you delete stops being restorable" in step 2.
- It took its current shape in release 1.0.0, on 2026-07-27. Note the wording there is "now protects…", which announces a widening of scope rather than a new feature — and the V3 in the name says there were earlier versions. I didn't check what form of GC, if any, existed in the 0.25.x I was running.
I haven't run it. So how its results compare to doing this by hand is something this post can't answer. But faced with the same situation now, the right first move is checking whether your version has that command before writing queries by hand.
The real conclusion: it comes back
That cleanup ran on 2026-07-20. The numbers I measured on 2026-08-11: 5,739 orphaned chunks, a 55.9MiB database file.
"Back where it was" actually understates it. The orphan count is at 73% of the 7,881 that were removed, and the database file is now larger than it was before that cleanup started (43.6MB). A bit over three weeks.
So "fixed" isn't the right word for any of this. The more often notes change, the faster chunks pile up — and this stack has bots and agents rewriting notes continuously, which makes it fast.
In the interest of honesty: automating the cleanup is planned and has not happened. The idea was a three-tier arrangement — CouchDB's own compaction to reclaim space after deletions (it can't find orphans, only free what's already been marked gone), a monthly orphan-chunk report pushed to me, and a semi-automatic delete when the report flags something — and I shelved it as too much hassle, so this stays a manual job for now. The reason the heartbeat watchdog in this series got automated and this didn't is that neglecting this one doesn't corrupt anything today. It bloats, and it keeps working.
Takeaways / checklist
- Compaction only strips superseded revisions; it can't see application-level orphans. Whether a live document is still referenced is a question only the application can answer, which makes the cleanup the application's problem — and whether that cleanup runs on its own or has to be triggered by hand is a separate thing to check.
- Before deleting, count the things that shouldn't be missing but already are. The dangling count tells you whether cleanup is even safe. Non-zero means investigate, not tidy.
- Keep the delete-then-compact order. Compacting first reclaims nothing, and it's very easy to read that as "this approach doesn't work."
- Treat recurring cleanup as cleanup. Write it down as a fix and you'll be staring at the same numbers three weeks later wondering how it regressed.