Definition
Text anchoring is the practice of storing coordinates so that a specific range in a web document can be found again later. Robust anchoring stores several clues of different kinds rather than one, scores candidates with weights when restoring, and declines to attach anything when confidence is insufficient.
Why it matters
The simplest way to save a highlight is a DOM path plus an offset.
// Almost guaranteed to break on the next visit
{ path: "body > article > p:nth-child(3)", start: 42, end: 58 }
Insert one ad, reorder a section, let a framework re-render a list, and the same coordinate now points at entirely different characters.
Storing only the quoted text has the opposite problem: when the same phrase appears several times you cannot tell which one was meant, and a single typo fix or reworded particle breaks exact matching.
That is why the W3C Web Annotation model stores multiple selectors together. If one breaks the others hold, and when several clues agree confidence rises. In most domains the decisive principle is that attaching to the wrong place is worse than not attaching at all, and that principle drives threshold design.
How it works
Step 1: turn the document into one normalized string
Walk the DOM, normalize to Unicode NFC, collapse runs of whitespace, and build a single string for the whole document. At the same time, preserve a mapping from normalized offset back to the original text node and offset.
// Whitespace must be handled per document, not per node
// <p>Google<b>Labs</b></p> → "GoogleLabs" (no space may appear in between)
Exclude SCRIPT, STYLE, hidden, and aria-hidden elements. Characters the user never saw would otherwise shift every index.
Step 2: save several selectors together
{
quote: { exact: "stop when confidence is low", prefix: "…96 chars before…", suffix: "…96 chars after…" },
position: { start: 10432, end: 10444 } // relative to the normalized string
}
Move element boundaries onto text boundaries and trim boundary whitespace before saving.
Step 3: restore with three candidate passes and weighted scores
| Pass | Method | Scoring behavior |
|---|---|---|
| Exact | Search for exact in the normalized string | Highest weight |
| Context | Use a slice of prefix/suffix (say 32 chars) to find nearby candidates | Medium weight |
| Fuzzy | Roll a token window and score similarity (Dice coefficient, etc.) | Capped low so it never auto-applies |
Normalize each candidate's score to 0–1 and split the decision three ways.
- Best score below the floor → unresolved; attach nothing.
- Gap between first and second place is small → ambiguous, even though candidates exist. This measures competition, not confidence, so it is needed separately from the threshold.
- At or above the ceiling → auto-highlight. Everything in between gets a marker without emphasis.
Step 4: verify after restoring
Re-normalize the contents of the Range you produced and compare it against the original quote. If they disagree, treat it as a failure. This is the last safety net.
Applying it
Keep thresholds as named constants in one place. They are domain-tunable values, and once scattered they become untouchable.
export const CONFIDENCE = {
LOW: 0.78, // below this: unresolved
HIGH: 0.9, // above this: auto-apply
AMBIGUITY_GAP: 0.08, // first-to-second gap smaller than this: ambiguous
} as const;
Capture timing matters just as much. Compute the anchor the moment the user releases the mouse and hold it as a value.
document.addEventListener("mouseup", () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
pendingAnchor = captureAnchor(selection.getRangeAt(0)); // freeze into a value now
});
Holding the Range object until the save button is clicked means a host-page re-render can swap the text nodes out from under you and the save silently fails.
Trade-offs
Storing several clues increases payload size and capture cost. Ninety-six characters of context on each side puts a single annotation in the hundreds of bytes.
Context length is a two-way trade-off: too short and repeated passages become ambiguous, too long and any small edit breaks the context match.
Fuzzy matching noticeably raises restore rates but brings misattachment risk along with it. Capping its score so it never auto-applies is the practical compromise.
When not to use it
- Static documents you control. If you can assign stable IDs, that is far cheaper and more accurate.
- Domains where a wrong attachment is catastrophic (legal signatures, medical records) — do not auto-apply fuzzy matches there. Leave them unresolved for a human.
- Screens where the entire document is regenerated on every visit. Put the identifier in the data model rather than in an anchor.
Common mistakes
- Trusting the position offset alone. One character of change shifts everything. Use it only as a bonus signal.
- Normalizing whitespace per node. A word split by inline tags gains a space that was never there, and exact matching fails.
- Omitting the ambiguity gap. With only a threshold, you pick the 0.91 candidate over the 0.90 one for no defensible reason.
- Including hidden elements in the index. Characters the user never saw throw every offset off.
- Computing the anchor at save time instead of at selection time. A single re-render of the host page makes it fail silently.
- Skipping the re-verification step. You lose the last chance to catch a high-scoring match that actually points somewhere else.
Related concepts
- browser-extension-message-based-rpc — the higher-level path that carries the restored result to other execution contexts
- test-oracle — the same principle ("do not answer when confidence is low") applied to correctness judgment