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.
What is the browser actually holding for each element, and why does it stop matching the HTML I wrote?
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 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.
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).
- 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
divinside apcloses the paragraph, atrwritten without atbodygets 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
inputnever changes itsvalueattribute.getAttribute('value')returns what the markup said;input.valuereturns 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.
innerHTMLround-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).
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,selectedset only the default), and some exist only as properties (offsetTop,dataset,_internalstate). - 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: nonesubtree 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,getBoundingClientRectandgetComputedStyleare 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 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
innerHTMLround-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.
roleandnameare 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
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).
| Pair | Attribute holds | Property holds | Sync direction | Where it bites |
|---|---|---|---|---|
id, class/className | Current value | Same value | Both ways | Rarely — which is why people generalise from it |
value on an input | The initial/default value | What the user has typed | Attribute → property once, at parse | Validation reads the attribute and never sees user input |
checked on a checkbox | Default checked state | Current checked state | Attribute → property once | A "reset" that writes the attribute changes nothing visible |
href on a link | Exactly the string written | Resolved absolute URL | Attribute → property, normalised | Comparisons against el.href fail for relative markup |
style | The declaration text | A live CSSStyleDeclaration | Both ways, per property | Writing el.style.width re-parses; writing the attribute replaces every inline declaration |
data-* | The string | dataset — strings only | Both ways | Everything is a string; a data-count of "0" is truthy |
aria-* | The string | Reflected property, plus element-reference IDL where supported | Both ways | Referencing 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
buttonproduces a node with a role, a name, keyboard behaviour and a default action for free; adivwith 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 associatedlabel,aria-labeloraria-labelledby, the state from properties likedisabled,checkedandaria-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, thehiddenattribute andvisibility: hiddenremove a subtree from the accessibility tree;opacity: 0, a zero-height clip or an offscreentransformdo 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
- Reading
getAttribute('value')orgetAttribute('checked')to find out what the user did. Both return the markup default forever (Controlled vs Uncontrolled Inputs). - Building markup with
innerHTMLfrom 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.
getElementByIdreturns the first,aria-labelledbyresolves to the first, and the second instance is silently mislabelled. - Assuming a node exists because the markup exists. A template, a
display: nonebranch 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).
- 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).
MutationObservercallbacks 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).
- 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,insertAdjacentHTMLanddocument.writeparse their input as markup. That is the difference that matters:textContentandcreateTextNodenever produce elements, so they are not injection sinks (Sanitization and Trusted HTML).- Attribute writes are conditionally dangerous. Setting
src,href,srcdoc,styleor anyon*attribute from untrusted data is an execution or exfiltration sink; settingdata-labelis 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).
- "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)andel.value = xdo 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
- 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).
- 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.
- 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
buttonis 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
titlecompetes 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.
- — 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.