Skip to content

obsidian/backlink-index

Answers “which references resolve to this file?” by looking only at the notes that COULD hold one, instead of resolving every reference in the vault.

Obsidian’s metadataCache.getBacklinksForFile(file) is not an index lookup. Read out of the 1.14.2 bundle, it is iterateAllRefs — every frontmatterLinks, links and embeds entry of every cached note, plus every reference each linkUpdaters entry (the canvas one) reports — with one getFirstLinkpathDest per reference, keeping those that resolve to file. That is ~115 ms at 100k references and ~320 ms at 250k, paid once per file by every per-file backlink lookup, so a 1000-note folder rename in a 50k-note vault took 924 s.

What makes a narrower answer exact is a property of Obsidian’s getLinkpathDest: every branch of it can only return a file whose lowercased name is the lowercased last / segment of the linkpath, or that segment plus .md. The one exception is the empty linkpath ([[#Heading]]), which returns the source note itself. So a note can hold a reference to file only if it holds a reference whose last segment matches one of file‘s name keys, or if it IS file. This module indexes notes by those segments, takes the candidates for file, and runs Obsidian’s own predicate — getFirstLinkpathDest(linkpath, source) === file — over the candidates’ references only. The candidates are a superset of the real backlinks and the predicate is the same one, so the answer is the one the full walk gives, a file registered at a non-existing path included. Only the ORDER of the notes in the answer differs: the file itself first, then the candidates in index order.

The index is kept fresh without listening to anything. metadataCache.fileCache maps a note to the hash of its content, and the references are a function of that hash, so each query compares every note’s hash against the one it was indexed at and re-reads only the notes that changed. That is one property comparison per note and no resolution at all, the same order as the scan Obsidian itself runs on every rename (updateRelatedLinks). Depending on no event also means depending on no event ORDER, which matters because rename handlers query from inside the vault rename event.

There is one index per App, held in the realm-global shared-state bag, so every plugin bundling this library shares it rather than each building its own. What is stored there is plain data read structurally (maps and sets, never a class instance), because the bag crosses copies of this library at different versions. The key carries a version: a change to what a key MEANS (how a name key is derived) must move to a new key, so an older copy never reads an index built by different rules.

The linkUpdaters side is still walked in full on every query. In 1.14.2 the only updater is the canvas one, whose references live in the canvas index rather than in fileCache, so there is no hash to key them by; a vault’s canvases hold a small fraction of its references.

Function Description
getIndexedBacklinksForFile The same answer as app.metadataCache.getBacklinksForFile(file) (Obsidian’s own, unpatched), from the candidate notes only. See the file header for why the answer is exact.