DOM, BOM, CSSOM — The Trees
The browser exposes three object models:
- DOM (Document Object Model) — the HTML element tree (
document,Node,Element,HTMLElement…). - BOM (Browser Object Model) — everything else the browser exposes (
window,navigator,location,history,screen). - CSSOM (CSS Object Model) — stylesheets parsed into JS objects (
document.styleSheets,CSSRule, computed styles).
Together they’re “what JS can touch in a page.” Senior fluency means knowing which API lives where, what’s available in workers (which strip DOM/BOM), and the cross-realm and same-origin pitfalls.
DOM
Node vs Element vs HTMLElement.
A class hierarchy:
Node
├── Document
├── Element
│ ├── HTMLElement
│ │ ├── HTMLDivElement
│ │ ├── HTMLInputElement
│ │ └── ...
│ └── SVGElement
├── Attr
├── CharacterData
│ ├── Text
│ └── Comment
└── DocumentFragmentNode— the base; everything in the DOM tree.Element— an XML/HTML element with attributes (no text nodes).HTMLElement—Elementsubclass with HTML-specific properties (style,dataset,innerText).HTMLInputElement/HTMLDivElement/etc. — per-tag subclasses with element-specific properties.
instanceof HTMLElement checks the entire subclass tree. Useful when narrowing Element | null to “an HTML element with a style property.”
document vs window — different things?
document is the DOM root (the HTML tree); window is the global object + Browser Object Model. document is a property of window.
window.document === document // true (in browsers, window's properties are global)
window.location // same as `location`
window.localStorage // same as `localStorage`In workers, window doesn’t exist; self is the global object equivalent (no DOM, no document).
Query selectors — getElementById, querySelector, getElementsByTagName.
| Returns | Live or static | |
|---|---|---|
getElementById(id) |
Element | null |
static; fastest |
querySelector(selector) |
Element | null |
static; first match |
querySelectorAll(selector) |
NodeList |
static: iterate with forEach |
getElementsByTagName(tag) |
HTMLCollection |
live: updates as the DOM changes |
getElementsByClassName(cls) |
HTMLCollection |
live, same |
Live vs static:
const list = document.getElementsByTagName("li"); // live
console.log(list.length); // 3
document.body.appendChild(document.createElement("li"));
console.log(list.length); // 4 — live updated
const list2 = document.querySelectorAll("li"); // static
console.log(list2.length); // 3
document.body.appendChild(document.createElement("li"));
console.log(list2.length); // 3 — snapshotLive collections used to be a perf trap (iterating one re-checks the DOM each iteration). Modern engines optimize; rarely matters.
innerHTML vs textContent vs innerText.
| What it sets | Layout, safety | |
|---|---|---|
innerHTML |
parsed as HTML | rebuilds the subtree; XSS risk, never user input |
textContent |
raw text, no parsing | no layout; safe |
innerText |
rendered text, whitespace and style aware | forces layout; safe |
el.innerHTML = "<script>alert(1)</script>"; // dangerous if user input
el.textContent = "<script>"; // literal string "<script>"
el.innerText = "Multi spaces\n\nlines"; // collapsed per CSStextContent is the right default for setting text. innerText is for reading what the user sees (respects display: none, line breaks via <br>, etc.).
Modern DOM mutation APIs.
Beyond appendChild:
el.append(child1, child2, "text"); // multiple, including strings
el.prepend(child); // insert at start
el.before(sibling); // insert before this element
el.after(sibling); // insert after
el.replaceWith(newEl); // swap this element
el.remove(); // remove this element from parent
// Older equivalents you may still see:
parent.insertBefore(child, ref);
el.parentNode.removeChild(el);append/prepend/before/after/replaceWith accept multiple nodes and strings. Cleaner than the older boilerplate.
Templates — <template> and DocumentFragment.
<template id="row">
<li class="item"><span class="name"></span></li>
</template>const tmpl = document.getElementById("row") as HTMLTemplateElement;
function makeRow(name: string) {
const clone = tmpl.content.cloneNode(true) as DocumentFragment;
clone.querySelector(".name")!.textContent = name;
return clone;
}
for (const name of names) {
list.append(makeRow(name)); // batched insert
}<template> is inert markup — not rendered, not run (<script> inside doesn’t execute). cloneNode(true) does a deep copy. Append the fragment, not the contents — fragments unwrap when appended.
Custom elements / Web Components.
class MyCounter extends HTMLElement {
static observedAttributes = ["count"];
#shadow = this.attachShadow({ mode: "open" });
connectedCallback() { this.render(); }
attributeChangedCallback(name, oldVal, newVal) { this.render(); }
render() {
this.#shadow.innerHTML = `<button>${this.getAttribute("count") ?? 0}</button>`;
}
}
customElements.define("my-counter", MyCounter);<my-counter count="5"></my-counter>A native class extending HTMLElement. Lifecycle hooks (connectedCallback, disconnectedCallback, attributeChangedCallback). Shadow DOM gives style + DOM encapsulation.
Used in design systems shipping to mixed-framework environments (Shoelace, Lit, Material Web Components). Less common inside a single-framework app.
BOM
What’s in the BOM?
| API | What |
|---|---|
window |
the global object + window-level methods (alert, setTimeout, etc.) |
navigator |
browser info (userAgent, language, clipboard, serviceWorker, geolocation) |
location |
current URL (href, pathname, search, hash) |
history |
session history (pushState, replaceState, back) |
screen |
display info (width, height, pixelDepth) |
localStorage/sessionStorage |
persistent storage (see Storage APIs — localStorage, sessionStorage, IndexedDB, Cookies, Cache API, OPFS) |
crypto |
Web Crypto API (random, hash, sign/encrypt) |
location and history navigation.
// Read
location.href; // full URL
location.pathname; // "/users/1"
location.search; // "?tab=settings"
location.hash; // "#section1"
// Navigate
location.href = "/somewhere"; // full page load
location.assign("/somewhere"); // same
location.replace("/somewhere"); // same but no history entry
location.reload();
// SPA routing without reload
// updates URL, no reload
history.pushState({ x: 1 }, "", "/users/2");
history.replaceState({ x: 2 }, "", "/users/3"); // replaces current entry
window.addEventListener("popstate", (e) => {
// user clicked back/forward; e.state is whatever you pushed
});pushState is the foundation of SPA routing libraries (React Router, Vue Router). New Navigation API (Chrome 102+) is the modern replacement but adoption is uneven; libraries still use pushState / popstate.
URL and URLSearchParams.
Built-in URL parsing — never roll your own:
const url = new URL("https://example.com/users/1?tab=settings&filter=active#bio");
url.protocol; // "https:"
url.hostname; // "example.com"
url.pathname; // "/users/1"
url.searchParams.get("tab"); // "settings"
url.searchParams.append("page", "2");
url.toString(); // updated
// Search params alone
const params = new URLSearchParams(location.search);
for (const [k, v] of params) console.log(k, v);Use these for any URL manipulation. Replaces hand-rolled regex parsing.
CSSOM
How do you read computed styles?
getComputedStyle(element):
const styles = getComputedStyle(el);
styles.color; // "rgb(0, 0, 0)" — always resolved (no "currentColor", no shortcuts)
styles.width; // "300px" — resolved to px
styles.getPropertyValue("--my-var"); // CSS custom propertygetComputedStyle is expensive — forces layout if you read a dimension. Cache the result; don’t call in tight loops.
Setting CSS from JS.
// Inline styles
el.style.color = "red";
el.style.setProperty("--brand-color", "#3b82f6");
el.style.transform = "translateX(10px)";
// Multiple at once
Object.assign(el.style, { color: "red", fontSize: "16px" });
// Toggle CSS classes
el.classList.add("active");
el.classList.remove("hidden");
el.classList.toggle("expanded");
el.classList.contains("expanded");
// Replace inline styles entirely
el.style.cssText = "color: red; font-size: 16px";Prefer classList over inline styles when possible — CSS classes are cacheable, themeable, and don’t compete with stylesheets.
document.styleSheets and dynamic rules.
Programmatic access to loaded stylesheets:
const sheet = document.styleSheets[0];
sheet.insertRule(".dynamic { color: blue; }", sheet.cssRules.length);
sheet.deleteRule(0);Used by CSS-in-JS libraries to inject rules at runtime. Rarely used directly in app code.
CSSStyleSheet constructor (constructable stylesheets).
Modern API — build a stylesheet object in JS, share across documents:
const sheet = new CSSStyleSheet();
sheet.replaceSync(".dynamic { color: blue; }");
// Attach to a shadow root or document
shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, sheet];
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];Shared, deduplicatable. Used by web component libraries for style isolation without runtime cost.
Gotchas
- Workers have no DOM —
document,window,localStorage,alertall unavailable. Use postMessage for communication. alert,confirm,promptblock the event loop — synchronously freeze the page until dismissed. Never use in production UI.document.writeafter page load wipes the document — historical footgun. Don’t use; ever.- Live HTMLCollections during iteration can cause “forever loop” bugs —
for (let i=0; i<list.length; i++) parent.appendChild(list[i])is undefined behavior. Snapshot withArray.from(list). querySelectoris slower thangetElementByIdin micro-benchmarks; matters only at extreme scale.- DOM mutations are sync, paint is async — modifying then reading
offsetWidthtriggers a synchronous layout but paint hasn’t happened yet (the user hasn’t seen the change). - Same-origin policy restricts cross-frame DOM access — an
<iframe>from a different origin’scontentDocumentthrows.
Related
Further reading
- MDN — DOM: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
- MDN — URL API: https://developer.mozilla.org/en-US/docs/Web/API/URL_API
- MDN — Web Components: https://developer.mozilla.org/en-US/docs/Web/API/Web_components
- WHATWG DOM Standard: https://dom.spec.whatwg.org/
Interview angle 6
- “DOM, BOM, CSSOM - what is the difference?” - the DOM is the parsed
document tree; the BOM is everything else the browser exposes around it -
window,navigator,location,history,screen; the CSSOM is the parsed stylesheets. Workers have neither DOM nor BOM, which is exactly why code has to be written differently for them. - “
textContent,innerTextorinnerHTML?” -textContentfor setting text, always, because it cannot execute anything.innerHTMLonly with content you trust or have sanitised.innerTextfor reading rendered text, and remember it forces layout because it depends on what is visible. - “How do you parse a URL?” - the
URLandURLSearchParamsAPIs, never a regular expression. They handle encoding, relative resolution and edge cases that a hand-written pattern gets wrong, and they are how SPA routing composes withhistory.pushStateandpopstate. - “What is the performance trap with
getComputedStyle?” - reading a computed dimension forces a synchronous layout. In a loop it is the classic thrashing pattern, so read once, cache the value, and do the writes afterwards. - “When are custom elements and shadow DOM useful?” - in a mixed-framework or long-lived environment, where a component must work regardless of what renders around it. Shadow DOM gives real style encapsulation, which is the part CSS conventions only approximate.
- “How do you share styles across shadow roots?” - a constructable
CSSStyleSheetwithadoptedStyleSheets, so one parsed sheet is shared by reference rather than duplicated into every root.