Web Platform · DOM and Events
How Ranges Survive Mutation: Boundary Points and Live Updates
A range is two boundary points that the DOM rewrites on every insertion and removal, and the adjustment rules explain both editing behaviour and the per-mutation cost of keeping ranges alive.
A Range stores no text and no nodes beyond two references. It stores a start boundary point and an end boundary point, each a pair of a node and an offset. Everything else, the string it converts to, the fragment it extracts, the rectangle it measures, is computed from those two points against the current tree. The design is compact, and it forces a question the API must answer on every mutation: when the tree changes underneath a range, where do the boundary points go?
Offsets mean two different things
The interpretation of an offset depends on the node it belongs to. For a CharacterData node, the offset is a position between UTF-16 code units, so a string of length 5 has offsets 0 through 5, and a character outside the Basic Multilingual Plane occupies two units. For an element or document, the offset is a child index, so an element with three children has offsets 0 through 3.
A boundary point is only valid if the node exists and the offset is within range. setStart and setEnd reject an offset greater than the node’s length with an IndexSizeError, and reject a doctype node with InvalidNodeTypeError. Selection.collapse applies the same checks, throwing IndexSizeError for an offset beyond the node’s length and InvalidNodeTypeError for a doctype, and the methods that build a range from selection endpoints inherit the same limits. Offsets computed from hit testing or from text length therefore have to be clamped by the caller; the platform will not correct them.
range.startContainer and startOffset are readable, and range.collapsed reports whether the two points are equal. Range comparisons use compareBoundaryPoints, whose mode constants are ordered START_TO_START (0), START_TO_END (1), END_TO_END (2), END_TO_START (3); the unusual ordering of the last two is part of the interface and is a common source of swapped comparisons.
Live ranges are updated by every mutation
The DOM keeps a list of all ranges that are not detached, and every insertion, removal, and character-data edit adjusts them. The rules are precise and short.
For an insertion into a parent at index i of n nodes: a boundary point whose node is that parent and whose offset is greater than i has its offset increased by n. A boundary point whose node is the parent and whose offset equals i stays, which is what makes an insertion at the start of a range extend the range backwards rather than shift it.
For a removal of a node at index i: a boundary point whose node is an inclusive descendant of the removed node is moved to the parent at index i, because the node it pointed into no longer exists. A boundary point in the parent with offset greater than i is decreased by one. The result is that a range covering exactly the removed node collapses to the removal position, while a range covering more than the node keeps its remaining content.
For CharacterData edits, insertData, deleteData, replaceData, and appendData shift offsets inside the same node. splitText(offset) is the special case: boundary points in the original text node with offset greater than the split point are moved to the newly created node with the split subtracted, so a range spanning the split point ends up spanning two nodes rather than breaking.
normalize() on a parent merges adjacent text nodes, and the same adjustment moves boundary points from the removed nodes into the survivor with recalculated offsets. A selection that survived a normalize therefore still covers the same characters, even though the nodes it references changed.
const p = document.querySelector('p'); // "hello world"
const range = document.createRange();
range.setStart(p.firstChild, 6); // before "world"
range.setEnd(p.firstChild, 11);
p.firstChild.splitText(6); // "hello " + "world"
// start stays at (first text node, 6); end moves to (second text node, 5)
console.log(range.toString()); // "world"
What live updating costs
The adjustment is not free. Every mutation walks the list of live ranges and applies the rules, so a document with many ranges pays per mutation for each of them. Most pages have very few: the current selection contributes one, and editing code creates ranges on demand. A page that creates ranges in a loop and keeps them alive accumulates that cost on every subsequent DOM change, even if the ranges are never read again. Dropping the reference does not detach the range; ranges are detached by explicit detach() in older code, though the call is now a no-op because garbage collection handles unreachable ranges.
StaticRange exists for the case where the snapshot semantics are wanted. Constructed from four values, it records a boundary pair and is not updated by mutation; it is also cheaper, because the platform does not have to track it. The trade is that a StaticRange can point at a removed node, so consumers must check validity. Range is the right type when the code wants the selection to follow the content; StaticRange is right when the code wants to remember where something was.
Extraction and the fragment boundary
extractContents() moves the selected content into a new DocumentFragment and removes it from the tree, using the same remove-then-attach sequence described in node identity and structural mutation, and cloneContents() copies it without removing. Both have to handle partial selections: a selection starting in the middle of a text node splits that node, and a selection ending inside an element forces a clone of the ancestor chain down to the cut point, so the fragment is well-formed even when the selection is not.
deleteContents() removes the same content without materialising it. surroundContents(node) wraps the selection in a single element, and it throws InvalidStateError if the range partially selects a non-text node, because there is no way to wrap a partial element without restructuring the tree. That restriction is the reason editor implementations rarely use surroundContents for arbitrary selections and instead extract, wrap, and reinsert.
range.toString() concatenates the text content of the selected nodes, ignoring element boundaries. It is a plain string of characters, with no indication of paragraph breaks or replaced elements; the fragment from cloneContents() is the representation that preserves structure.
Selections are ranges with direction
window.getSelection() returns a Selection that holds zero or more ranges. The common case is one range, retrievable with getRangeAt(0), but the API allows multiple for implementations that support them, and rangeCount must be checked before indexing.
The selection’s anchorNode/anchorOffset and focusNode/focusOffset record direction, which the underlying range does not: a selection dragged backwards has its focus before its anchor, while the range’s start is always the earlier point. collapse moves both ends to one point, and extend moves the focus while keeping the anchor, which is how a selection is grown programmatically without rebuilding a range.
Anchors and focus points are boundary points, so they are adjusted by mutations by exactly the rules above. That is why deleting a paragraph while text is selected leaves the selection collapsed at the deletion point instead of pointing at removed nodes.
selectionchange is dispatched on the document rather than on the element whose selection changed, so observers of a specific editable region have to filter by checking whether the selection intersects their subtree. The event is not guaranteed to be synchronously dispatched for every intermediate state during a drag; it reflects the selection at the time it is delivered, which makes it unsuitable for per-character tracking and appropriate for invalidation.
Caret positions and the geometry of a point
document.caretPositionFromPoint(x, y) returns the text node and offset at a viewport coordinate, and caretRangeFromPoint(x, y) returns the equivalent collapsed range. Both resolve a point through hit testing against the rendered text, using the topmost-box rule that also routes pointer events, which means they depend on layout being current and they account for transforms and clipping.
The returned offsets are UTF-16 offsets into the text node, matching the range model, so a caret position can be converted directly into a collapsed range and used to insert content. A hit where no insertion point would be drawn returns null rather than snapping to nearby text, which is why editors that place a caret on an empty line need an empty element or a placeholder text node to target.
The geometry of a range itself is available through getBoundingClientRect and getClientRects, which return the union rectangle and the per-line rectangles respectively. For a multi-line selection, the per-line list is the one that matters: the union rectangle covers the whole block and says nothing about where the line breaks are, so highlighting or caret placement built from the union alone puts boxes in places the text does not occupy. These calls force layout like any other geometry read, which is why a range measured in a loop over many ranges is a performance problem rather than a convenience.
References
- DOM Standard: Ranges — WHATWG
- Selection API — W3C
- CSSOM View Module — CSS Working Group