DOMGENERALENGINE-SPECIFICBROWSER-SPECIFIC

The DOM Is Not Your HTML

A tree of live objects the parser built from your markup and script has since diverged from — each node carrying attributes, properties, listeners, computed style, a layout box and an accessibility role.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What is the browser actually holding for each element, and why does it stop matching the HTML I wrote?

The user intent

A person wants a page whose content they can read, whose controls they can operate, and which visibly changes when they act on it.

The obvious build

The DOM is the HTML. querySelector finds the tag from my source file, and if I want to know what is on the page I look at View Source. The tree and the markup are the same thing in two formats.

Why it breaks

View Source shows the bytes the server sent. The Elements panel shows the tree the parser built and script has mutated since. On any page that renders or hydrates client-side these diverge within the first frame (Tree Construction).

How it breaks in a real browser
  • View Source shows the bytes the server sent. The Elements panel shows the tree the parser built and script has mutated since. On any page that renders or hydrates client-side these diverge within the first frame (Tree Construction).
  • The parser repairs markup according to spec rules, not your intent: a div inside a p closes the paragraph, a tr written without a tbody gets one, and content that cannot live inside a table is foster-parented out of it. The element you wrote is not always the element you got.
  • Typing into an input never changes its value attribute. getAttribute('value') returns what the markup said; input.value returns what the user typed. Reading the wrong one produces a bug that only appears after someone types.
  • A node carries things that have no representation in markup at all — attached listeners, a computed style, a layout box, a selection range, media playback position, custom element internals. innerHTML round-tripping destroys every one of them (What a Mutation Costs).
  • Two nodes with identical markup are still two different objects. Focus, scroll position and caret live on the object, not on the tag (Node Identity Across Updates).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The HTML parser produces the tree, obeying insertion rules that are part of the specification rather than of your file. The result is deterministic across browsers precisely because those repairs are specified (Tree Construction).
  • Every node is a live object with identity. document.getElementById('x') === document.getElementById('x') is true: a query returns a reference to the existing object, never a copy of it.
  • Attributes are the serialized initial configuration. Properties are the current state of the object. Some reflect each other in both directions (id, className, href), some are one-way (value, checked, selected set only the default), and some exist only as properties (offsetTop, dataset, _internal state).
  • Computed style is not on the node in any form you wrote. It is the result of running the cascade over the CSSOM for that element, and the browser recomputes it lazily on its own schedule (Style Calculation, Inheritance and Computed Style).
  • Layout boxes exist only after layout ran, and only for nodes that generate one. A display: none subtree is in the DOM with no geometry at all, which is why reading its size returns zeros (The Box Model).
  • The accessibility tree is a third derived structure: role, name, description, state and relationships computed from the element type, its attributes, its ARIA and its label associations, then handed to platform assistive technology (The Accessibility Tree).
  • So there is one source of truth and several projections. Change the DOM and every projection downstream of it is marked stale; the browser decides when to recompute each one.

What this makes the browser do

And which of it is avoidable.

  • Allocating one object per element, text node, comment and attribute list. Memory scales with node count, not with the byte size of the HTML — a 20 KB page that expands into 30,000 nodes is the expensive one.
  • Maintaining invalidation state: which subtrees need style recalculated, which boxes need layout, which regions need repainting (Style Invalidation).
  • Serving synchronous reads by flushing pending work. offsetHeight, getBoundingClientRect and getComputedStyle are queries about layout, so asking for them mid-mutation forces layout to run right now (Layout Thrashing).
  • Computing and updating the accessibility tree in parallel and pushing it across a process boundary to the platform accessibility API (The Multi-Process Browser).
  • Avoidable: nodes nobody can perceive. Wrapper elements added for convenience (Div Soup: How It Happens and What It Costs), a hidden duplicate of the navigation for small screens, and every row of a 10,000-row table the user will scroll past (List Virtualization).

One tree, three projections

Bytes become tokens, tokens become a tree, and everything the user experiences is derived from that tree. Style pairs it with the CSSOM to produce computed values; layout turns those into boxes; paint turns boxes into drawing commands; and a separate derivation produces the accessibility tree that a screen reader, a switch device or voice control actually consumes.

Reading the diagram as a dependency graph is what makes it useful. The DOM is upstream of all three projections, which is why a single mutation can cost style, layout, paint *and* an accessibility update — and why the interesting question is never "did the DOM change" but "which projections did that change invalidate" (The Cost of a Change).

What is derived from the DOM
mutatesrole, name, statevisibility decides inclusionHTML bytesScript mutationsCSSOMTokenizer + tree constructionDOM — live node objectsComputed style per elementLayout boxesAccessibility treePaint + compositeAssistive technologyPixels
UserLLMAgentToolDataDecisionHumanGuardrail

What a node actually carries

This is the point of a node explorer: click an element and see everything the browser is holding for it, not just the tag. Almost none of the interesting content came from the markup, and almost every hard bug in this domain lives in one of the lower rows.

The dump below is what one ordinary button looks like from the browser's side. Note that the tag and its two attributes are four lines of it; the rest is state that no amount of reading the HTML file would reveal.

  • Attributes vs properties — two stores, sometimes synchronised, often not. See the next section.
  • Listeners are invisible in markup and invisible in the serialized tree. They are why innerHTML round-tripping is lossy and why detached subtrees leak (Detached Nodes and What Keeps Them Alive).
  • Computed style is a value per property per element, produced by the cascade — not the declarations you wrote (The Cascade).
  • The layout box exists only after layout ran, only for rendered nodes, and reading it can force layout to run now (Layout Thrashing).
  • Accessibility semantics are computed, not declared. role and name are the two you should be able to read at a glance for any interactive node (The Accessibility Tree).
<button id="save" class="btn btn--primary" disabled>  Save  </button>

  tag              button
  attributes       id="save"  class="btn btn--primary"  disabled=""
  properties       id="save"  className="btn btn--primary"
                   disabled=true        <- reflects the attribute
                   value=""             <- does NOT reflect anything
                   dataset={}           isConnected=true
  children         1 text node: "  Save  "   (whitespace is a node)
  listeners        click     (bubble, from app.js:212)
                   keydown   (capture, from a11y-shim.js:44)  <- you did not add this
  computed style   display:inline-flex  color:rgb(255,255,255)
                   pointer-events:none  (from .btn[disabled])
  layout box       x:812 y:64 w:96 h:36   border-box, in flow
                   (zero if display:none — the node still exists)
  a11y             role:      button
                   name:      "Save"      (trimmed from the text node)
                   state:     disabled, not focusable
                   relations: aria-describedby -> #save-hint (resolves)

Attributes are not properties

SPEC-EVOLVINGThe element-reference IDL properties for ARIA relationships (setting ariaLabelledByElements to real element references instead of id strings) are the mechanism intended to fix cross-root references, and support is still arriving. Id strings work everywhere today; element references are the direction of travel, so feature-detect rather than assume either (Shadow DOM and the Composed Tree).

This is the single most reliable source of "the DOM is lying to me" bug reports, and the confusion is understandable: for id and class the two really are the same value, which teaches exactly the wrong general rule.

The rule that actually holds: an attribute is what the markup said, and a property is what the object is. Where the two are kept in sync it is because the specification says so for that specific attribute. Where a user can change state — form fields, above all — the attribute is frozen at its initial value on purpose, because that is what a form reset restores (Native Forms First).

PairAttribute holdsProperty holdsSync directionWhere it bites
id, class/classNameCurrent valueSame valueBoth waysRarely — which is why people generalise from it
value on an inputThe initial/default valueWhat the user has typedAttribute → property once, at parseValidation reads the attribute and never sees user input
checked on a checkboxDefault checked stateCurrent checked stateAttribute → property onceA "reset" that writes the attribute changes nothing visible
href on a linkExactly the string writtenResolved absolute URLAttribute → property, normalisedComparisons against el.href fail for relative markup
styleThe declaration textA live CSSStyleDeclarationBoth ways, per propertyWriting el.style.width re-parses; writing the attribute replaces every inline declaration
data-*The stringdataset — strings onlyBoth waysEverything is a string; a data-count of "0" is truthy
aria-*The stringReflected property, plus element-reference IDL where supportedBoth waysReferencing an id that does not exist fails silently — nothing warns you (The Rules of ARIA)

How to build it

Most important first.

  • Reason about the Elements panel, never View Source. They answer different questions, and only one of them describes the page that exists right now (A Mental Model of the Devtools).
  • Before debugging, name which layer you are asking about: the markup, the node object, an attribute, a property, an attached listener, computed style, the layout box, or the accessibility semantics. Most "the DOM is lying to me" bugs are a question asked of the wrong layer.
  • Choose elements so the accessibility projection is correct by construction. A button produces a node with a role, a name, keyboard behaviour and a default action for free; a div with a click handler produces a node with none of them (Semantics Are Behaviour).
  • Keep node count proportional to what a person can actually perceive at once. Every node costs memory, style recalculation, layout and an accessibility node forever.
  • Mutate the specific thing that changed rather than re-serialising a subtree. Replacing markup you did not change is the single most common way to lose focus, selection and listeners (What a Mutation Costs).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The accessibility tree is computed *from this tree*: the role comes from the element or an explicit role, the name from content, an associated label, aria-label or aria-labelledby, the state from properties like disabled, checked and aria-expanded, and the relationships from id references.
  • Id-based relationships — for, aria-labelledby, aria-describedby, aria-controls, aria-activedescendant — are resolved within a single tree by id. They break silently across a shadow boundary (Shadow DOM and the Composed Tree) and resolve to the wrong element when a component duplicates ids.
  • Which nodes reach the projection is a style question, not a markup one: display: none, the hidden attribute and visibility: hidden remove a subtree from the accessibility tree; opacity: 0, a zero-height clip or an offscreen transform do not. A "closed" menu implemented by moving it offscreen is still announced and still focusable (Semantics Before ARIA).
  • This is why a node inspector should show role, accessible name and state next to the tag and attributes. If a developer cannot read the semantics of a node in the same place they read its markup, the semantics were never designed — they were inherited by accident.
  • Text nodes matter. Whitespace between elements is a node, and an accessible name computed from content concatenates what is there — which is how a button ends up named "Save Save" or " ".

What can go wrong

Failure modes
  • Reading getAttribute('value') or getAttribute('checked') to find out what the user did. Both return the markup default forever (Controlled vs Uncontrolled Inputs).
  • Building markup with innerHTML from data that came from anywhere but your own code. The parser will construct whatever nodes the string describes (Cross-Site Scripting).
  • Repeated ids from a component rendered more than once. getElementById returns the first, aria-labelledby resolves to the first, and the second instance is silently mislabelled.
  • Assuming a node exists because the markup exists. A template, a display: none branch and an unhydrated island are all in the document and none of them behave like a rendered element (Hydration).
  • Assuming the accessibility tree is current. When the main thread is blocked, the DOM is updated in your code but the projection handed to a screen reader is not (Long Tasks).
What can arrive out of order
  • The accessibility tree is updated asynchronously with respect to your mutation, and is delivered to assistive technology across a process boundary. Code that mutates and immediately expects an announcement is racing that pipeline (Live Regions and Announcement).
  • MutationObserver callbacks are delivered at the microtask checkpoint, after the mutating task finishes — so several mutations you made separately arrive as one batch, in tree order rather than in the order you made them (The Microtask Checkpoint).
Security
  • Any script running in the page can read the entire tree: values typed into inputs, tokens rendered into markup, data- attributes, and anything a server template embedded. There is no per-node access control in the DOM (Third-Party Scripts and the Supply Chain).
  • innerHTML, outerHTML, insertAdjacentHTML and document.write parse their input as markup. That is the difference that matters: textContent and createTextNode never produce elements, so they are not injection sinks (Sanitization and Trusted HTML).
  • Attribute writes are conditionally dangerous. Setting src, href, srcdoc, style or any on* attribute from untrusted data is an execution or exfiltration sink; setting data-label is not (Cross-Site Scripting).
  • A Content-Security-Policy restricts what can execute and what can be loaded. It does not make DOM contents unreadable, and it does not stop a same-origin script from reading your form fields (Content Security Policy).
  • Browser extensions, translation tools and injected scripts rewrite the tree under you. Structure is not trusted input, and code that assumes its own markup survived unmodified will break in the field (The Browser Security Model).
Misreads
  • "The DOM is slow." Reading a property off a node object is cheap. What is expensive is what a *mutation* invalidates, and what a *layout-reading* property forces the browser to compute immediately (What a Mutation Costs).
  • "View Source shows the DOM." It shows the response body. On a client-rendered page the two have almost nothing in common.
  • "A virtual DOM avoids the DOM." It batches and diffs so that fewer mutations reach the real tree. Every visible change still goes through these same node objects at the same cost (Reconciliation and Keys).
  • "setAttribute('value', x) and el.value = x do the same thing." One sets the default, the other sets the current state, and the difference is invisible until a user has typed.
  • "If it is in the DOM, a screen reader reads it." Whether a node reaches the accessibility tree depends on its computed style and its semantics, not on its presence (The Accessibility Tree).

Measuring it, and what changes in the field

How you would see this
  • The Elements panel is the live tree; the Accessibility pane beside it is the projection. Reading both for the same node is the fastest accessibility check that exists (A Mental Model of the Devtools).
  • DOM node count over time, from the Performance panel's memory track — a count that only ever rises across route changes is the signature of retention (Detached Nodes and What Keeps Them Alive).
  • A heap snapshot attributes retained size to detached nodes and names what is holding them (Debugging Memory).
  • Style recalculation and layout entries in a Performance recording are how you see the cost of the tree's size, rather than guessing at it (Debugging Rendering and Jank).
Slow device, slow network, large data, old tab
  • On a slow device, per-node work dominates: style recalculation, layout and accessibility-tree maintenance all scale with node count, so a tree that is merely large on a laptop is a stutter on a phone.
  • On a large dataset the tree is the constraint before the data is. Rendering every row of a result set is a node-count decision disguised as a rendering decision (List Virtualization).
  • In a long-lived tab the tree accumulates: modals that were never removed, toasts, detached fragments held by caches. The shape after an hour is a different question from the shape on load (Long-Lived Clients and Version Skew).
  • Under a translation extension or a user stylesheet, your text nodes and computed styles are not the ones you shipped.
What this costs
  • Fewer wrapper nodes means less browser work and a cleaner accessibility tree, but costs you the hooks that make some CSS layouts easy. The honest position is that wrappers are a real cost worth paying deliberately, not a free abstraction.
  • Semantic elements bring behaviour and default styling you then have to work with. Resetting a button is a few lines; reimplementing one correctly is a lesson (What Native Elements Already Do).
  • Thinking in terms of the node object rather than the markup is a harder mental model to hold, and it does not help at all until something behaves in a way the markup cannot explain — at which point it is the only model that does.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALNode objects, the attribute/property split, parser repair rules and the derivation of an accessibility tree from the DOM are specified behaviour and are consistent across Blink, Gecko and WebKit. Where they differ is in timing, not in structure.
  • ENGINE-SPECIFICAccessible name computation has known divergences at the edges — how much whitespace is collapsed, how title competes with content, how nested labels resolve. Chromium, Gecko and WebKit each map roles onto a different platform accessibility API, so the same DOM can be described differently by a screen reader on Windows and on macOS.
  • BROWSER-SPECIFICThe node inspector this lesson describes is a devtools feature: Chromium shows computed accessibility properties in an Accessibility pane, Firefox has a separate Accessibility panel with its own tree view, and Safari surfaces a narrower subset. The information exists in every engine; the panel that shows it does not.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Computer Architecturepointer-chasing
Domains that do not exist yet
  • Programming Languages & Runtime Internals — the DOM node objects are host objects, not ordinary JavaScript objects, and property access on them crosses into engine-internal representations.