Browser APIs
22 min read

DOM API Essentials: Structure, Traversal, and Mutation

A comprehensive exploration of DOM APIs, examining the interface hierarchy design decisions, selector return type differences, and the modern Observer pattern for efficient DOM monitoring. The DOM Standard (WHATWG Living Standard, last updated January 2026) defines a layered inheritance model where each interface adds specific capabilities while maintaining backward compatibility—understanding this design reveals why certain methods exist on Element rather than HTMLElement and why selector APIs return different collection types with distinct liveness semantics.

EventTarget

Node

Element

HTMLElement

HTML-specific

SVGElement

SVG-specific

MathMLElement

MathML-specific

DOM interface inheritance hierarchy showing Element as the universal base for all markup types

The Document Object Model (DOM) API exposes document structure through a layered interface hierarchy designed for cross-markup compatibility. The core mental model:

Observer Pattern

async

async

async

IntersectionObserver

Batched callbacks

MutationObserver

ResizeObserver

Collection Liveness

static

live

live

querySelectorAll()

NodeList snapshot

getElementsByClassName()

HTMLCollection auto-updates

childNodes

NodeList auto-updates

Capability Layers

EventTarget

Events only

Node

Tree structure

Element

Cross-markup ops

HTMLElement

HTML semantics

DOM APIs organize around three concerns: interface hierarchy for capability separation, collection types for liveness semantics, and Observer pattern for efficient change detection

Key design decisions:

  • Element vs HTMLElement split: Methods like querySelector() and classList live on Element (not HTMLElement) because they must work across HTML, SVG, and MathML—the hierarchy reflects cross-markup requirements, not implementation convenience
  • Live vs static collections: getElementsByClassName() returns live HTMLCollection (auto-updates), while querySelectorAll() returns static NodeList (snapshot)—choose based on whether you need real-time tracking or one-time processing
  • Observer async batching: All Observer APIs deliver notifications asynchronously in batches, enabling browser-internal optimizations impossible with synchronous event listeners

The DOM Standard (WHATWG Living Standard) establishes a layered inheritance model where each interface adds capabilities while remaining backward compatible with its ancestors. The spec defines the DOM as “a platform-neutral model for events, aborting activities, and node trees.”

Element serves as the universal base class for all markup languages because DOM operations must work across different document types. Consider these scenarios:

// This SVG circle is an Element, but NOT an HTMLElement
const circle = document.querySelector("circle")
circle.setAttribute("r", "50") // Element method works
circle.classList.add("active") // Element method works
// HTMLElement methods would fail on SVG elements
// circle.contentEditable // undefined - this is HTML-specific
// circle.dataset // undefined - this is HTML-specific

The browser’s internal class hierarchy looks like this:

EventTarget (base for all event-capable objects)
Node (tree participation)
Element (universal element operations)
├── HTMLElement (HTML-specific: contentEditable, dataset, innerText)
├── SVGElement (SVG-specific: SVG DOM properties)
└── MathMLElement (MathML-specific: mathematical markup)

Design insight: Methods defined on Element work for HTML, SVG, and MathML elements. Methods requiring HTML-specific semantics live on HTMLElement. This separation ensures querySelector(), getAttribute(), and classList function identically whether you’re manipulating <div>, <svg>, or <math> elements.

adds

adds

adds

adds

EventTarget

Event System

Node

Tree Operations

Element

Attributes & Selectors

HTMLElement

HTML Semantics

addEventListener

dispatchEvent

parentNode

childNodes

appendChild

querySelector

getAttribute

classList

contentEditable

dataset

innerText

Each interface layer adds specific capabilities while inheriting all parent functionality

EventTarget (EventTarget)

  • Event registration and dispatch
  • Foundation for all interactive objects
  • No DOM tree awareness

Node (Node extends EventTarget)

  • Tree structure participation
  • Parent/child/sibling relationships
  • Node types (element, text, comment, document)
  • parentNode, childNodes, firstChild, lastChild
  • appendChild(), removeChild(), insertBefore()

Element (Element extends Node)

  • Attribute management: getAttribute(), setAttribute(), hasAttribute()
  • CSS selector queries: querySelector(), querySelectorAll(), matches(), closest()
  • Class manipulation: classList, className
  • Geometry: getBoundingClientRect(), scrollIntoView()
  • Namespace-aware operations for XML-based documents
  • Works for HTML, SVG, MathML, and other XML vocabularies

HTMLElement (HTMLElement extends Element)

  • HTML-specific content: innerText, outerText
  • Editability: contentEditable, isContentEditable
  • Data attributes: dataset (access to data-* attributes)
  • Form interaction: autofocus, tabIndex, hidden
  • Input hints: inputMode, enterKeyHint, autocapitalize
  • Accessibility shortcuts: accessKey, title
  • Internationalization: lang, dir, translate

The hierarchy determines method availability at compile and runtime:

// TypeScript enforces hierarchy
const element: Element = document.querySelector("div")!
element.classList.add("active") // ✅ Element method
element.setAttribute("role", "tab") // ✅ Element method
// @ts-error: Property 'dataset' does not exist on type 'Element'
element.dataset.userId = "123" // ❌ HTMLElement-specific
// Type narrowing required
if (element instanceof HTMLElement) {
element.dataset.userId = "123" // ✅ Now TypeScript knows it's HTMLElement
element.contentEditable = "true" // ✅ HTML-specific property
}
// SVG elements demonstrate the distinction
const svg = document.querySelector("svg")!
svg.classList.add("icon") // ✅ Element method works
svg.setAttribute("viewBox", "0 0 100 100") // ✅ Element method works
// svg.innerText = 'text'; // ❌ Would fail - innerText is HTMLElement-specific

Why this matters: When writing reusable utilities that manipulate both HTML and SVG elements, typing parameters as Element rather than HTMLElement ensures compatibility across markup types.


DOM selector methods return two distinct collection types with different liveness characteristics and capabilities (HTMLCollection vs NodeList).

MethodReturn TypeLive/StaticNode Types
querySelectorAll()NodeListStaticElements only
getElementsByClassName()HTMLCollectionLiveElements only
getElementsByTagName()HTMLCollectionLiveElements only
getElementsByName()NodeListLiveElements only (unusual exception)
childNodesNodeListLiveAll nodes (text, comment, element)
childrenHTMLCollectionLiveElements only

HTMLCollection maintains active references to matching elements, automatically reflecting DOM changes:

const container = document.getElementById("list")
const items = container.getElementsByClassName("item")
console.log(items.length) // 3
// Add a new element with class 'item'
const newItem = document.createElement("div")
newItem.className = "item"
container.appendChild(newItem)
console.log(items.length) // 4 - automatically updated!

Liveness implications:

// ⚠️ Infinite loop: collection updates as you modify the DOM
const items = document.getElementsByClassName("item")
for (let i = 0; i < items.length; i++) {
items[i].classList.remove("item") // Removes element from collection
// Now items.length decreased, but i increased
// You'll only process every other element
}
// ✅ Convert to array to capture snapshot
const itemsArray = Array.from(document.getElementsByClassName("item"))
for (const item of itemsArray) {
item.classList.remove("item") // Safe: iterating over static array
}

No array methods:

const items = document.getElementsByClassName("item")
// ❌ TypeError: items.forEach is not a function
items.forEach((item) => console.log(item))
// ✅ Convert to array first
Array.from(items).forEach((item) => console.log(item))
// ✅ Or use spread operator
;[...items].forEach((item) => console.log(item))
// ✅ Traditional for loop works
for (let i = 0; i < items.length; i++) {
console.log(items[i])
}
// ✅ for...of works (HTMLCollection is iterable)
for (const item of items) {
console.log(item)
}

NodeList from querySelectorAll() captures document state at query time:

const items = document.querySelectorAll(".item")
console.log(items.length) // 3
const newItem = document.createElement("div")
newItem.className = "item"
document.body.appendChild(newItem)
console.log(items.length) // Still 3 - NodeList doesn't update
// Must re-query to see new elements
const updatedItems = document.querySelectorAll(".item")
console.log(updatedItems.length) // 4

forEach support:

const items = document.querySelectorAll(".item")
// ✅ NodeList has native forEach
items.forEach((item, index) => {
item.dataset.index = String(index)
})
// ✅ Also supports for...of
for (const item of items) {
console.log(item)
}
// ⚠️ But still not a real Array
items.map((item) => item.textContent) // ❌ TypeError: items.map is not a function
// ✅ Convert for full array methods
const texts = Array.from(items).map((item) => item.textContent)

Exception: Live NodeList:

The childNodes property returns a live NodeList. Additionally, getElementsByName() returns a live NodeList (not HTMLCollection)—an unusual API design exception:

const parent = document.getElementById("container")
const children = parent.childNodes // Live NodeList
console.log(children.length) // Includes text nodes, comments, elements
parent.appendChild(document.createElement("div"))
console.log(children.length) // Automatically increased
// getElementsByName() also returns live NodeList (unusual exception)
const namedElements = document.getElementsByName("email")
// This NodeList updates when matching elements are added/removed

Iteration caveat: Never use for...in to enumerate NodeList items—it will also enumerate length and item properties. Use for...of, forEach(), or convert to array.

Live HTMLCollection

Static NodeList

DOM Query

Collection Type

Maintains References

Snapshot Copy

Overhead: Monitor DOM

Benefit: Auto-updates

Overhead: Re-query for updates

Benefit: No maintenance cost

Trade-offs between live collections that auto-update vs static snapshots

Live collections (HTMLCollection, childNodes):

  • Cost: Browser maintains internal references and updates collection on every DOM mutation
  • Benefit: Always current without re-querying
  • Use when: Need real-time DOM state and will access collection multiple times over time

Static collections (NodeList from querySelectorAll()):

  • Cost: Must re-query to see DOM changes
  • Benefit: No ongoing maintenance overhead
  • Use when: Processing elements once or DOM is stable during iteration

Benchmark insight: Static querySelectorAll() is faster for one-time operations. Live collections amortize cost when accessed repeatedly across multiple DOM mutations.

// ✅ Use querySelectorAll for one-time processing
function highlightAllWarnings() {
const warnings = document.querySelectorAll(".warning")
warnings.forEach((warning) => {
warning.style.backgroundColor = "yellow"
})
}
// ✅ Use getElementsByClassName when DOM changes frequently
function setupLiveCounter() {
const items = document.getElementsByClassName("cart-item")
function updateCount() {
document.getElementById("count").textContent = String(items.length)
}
// Collection automatically reflects added/removed items
document.addEventListener("DOMContentLoaded", updateCount)
document.addEventListener("cartUpdate", updateCount)
}
// ✅ Convert live to static when iterating and modifying
function removeAllItems() {
const items = document.getElementsByClassName("item")
Array.from(items).forEach((item) => item.remove())
}
// ✅ Cache length for performance in loops
function processItems() {
const items = document.querySelectorAll(".item")
const length = items.length // Cache length
for (let i = 0; i < length; i++) {
// Process items[i]
}
}

Modern Observer APIs provide performant, callback-based change detection without polling or continuous event listener execution.

All Observer APIs follow the same interface design:

// 1. Create observer with callback
const observer = new ObserverType((entries, observer) => {
entries.forEach((entry) => {
// Handle changes
})
})
// 2. Start observing targets
observer.observe(targetElement, options)
// 3. Stop observing specific target
observer.unobserve(targetElement)
// 4. Stop observing all targets
observer.disconnect()
// 5. Get pending notifications (some observers)
const records = observer.takeRecords()

IntersectionObserver monitors when elements enter or exit specified boundaries (typically the viewport), enabling lazy loading and scroll-based interactions without scroll event listeners.

Core concepts:

  • Root: Bounding box for intersection testing (viewport by default, or ancestor element)
  • Root margin: CSS-style margin offsets applied to root’s bounding box
  • Threshold: Visibility ratios (0.0 to 1.0) that trigger callbacks
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// entry.target - the observed element
// entry.isIntersecting - whether target intersects root
// entry.intersectionRatio - percentage of target visible (0.0 to 1.0)
// entry.intersectionRect - visible portion dimensions
// entry.boundingClientRect - target's full bounding box
// entry.rootBounds - root element's bounding box
// entry.time - timestamp when intersection changed
})
},
{
root: null, // viewport (null) or ancestor Element
rootMargin: "0px", // margin around root
threshold: [0, 0.5, 1.0], // trigger at 0%, 50%, 100% visibility
},
)
observer.observe(document.querySelector("#target"))

The threshold option controls what percentage of the target element must be visible before the callback fires. Default value: 0 (callback fires when even a single pixel becomes visible).

Single threshold value:

// Fire when 50% of element is visible
const observer = new IntersectionObserver(callback, { threshold: 0.5 })
// Common threshold values:
// 0 - Fire immediately when any part enters (default)
// 0.5 - Fire when half visible
// 1.0 - Fire only when fully visible

Multiple thresholds for progressive tracking:

// Track visibility at multiple stages
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// Callback fires at each threshold crossing
console.log(`Visibility: ${Math.round(entry.intersectionRatio * 100)}%`)
if (entry.intersectionRatio >= 0.75) {
// Element is mostly visible - count as "viewed"
trackImpression(entry.target)
}
})
},
{ threshold: [0, 0.25, 0.5, 0.75, 1.0] },
)

Threshold behavior:

  • Callbacks fire when visibility crosses a threshold in either direction (entering or leaving)
  • With threshold: 0, callback fires when element goes from 0% → any visibility AND from any visibility → 0%
  • With threshold: 1.0, callback fires only when element is 100% visible (or leaves 100% visibility)

The rootMargin option adjusts the root’s bounding box before calculating intersections, using CSS margin syntax. Default value: "0px 0px 0px 0px".

Viewport (root)

Expanded Zone (positive rootMargin)

Intersects viewport

Intersects expanded zone

Visible Content Area

Element A

Triggers early

Element B

In viewport

Positive rootMargin expands the detection zone beyond the viewport, triggering callbacks before elements become visible

Syntax follows CSS margin shorthand (top, right, bottom, left):

// Single value: applies to all sides
rootMargin: "50px" // Expand all sides by 50px
// Two values: vertical | horizontal
rootMargin: "50px 0px" // Expand top/bottom by 50px
// Four values: top | right | bottom | left
rootMargin: "100px 0px 50px 0px" // Expand top 100px, bottom 50px

Positive vs negative values:

// Positive: EXPAND detection zone (trigger BEFORE element is visible)
const preloadObserver = new IntersectionObserver(callback, {
rootMargin: "200px", // Start loading 200px before entering viewport
})
// Negative: SHRINK detection zone (trigger AFTER element is well inside)
const deepVisibilityObserver = new IntersectionObserver(callback, {
rootMargin: "-100px", // Only trigger when 100px inside viewport
})

Practical rootMargin patterns:

// Preload content before it scrolls into view
const lazyLoader = new IntersectionObserver(loadContent, {
rootMargin: "300px 0px", // 300px buffer above and below viewport
})
// Sticky header detection - trigger when element is near top
const stickyObserver = new IntersectionObserver(updateSticky, {
rootMargin: "-80px 0px 0px 0px", // 80px from top edge (header height)
})
// Analytics: only count as "viewed" if visible for meaningful time
const impressionObserver = new IntersectionObserver(trackView, {
rootMargin: "-50px", // Must be 50px inside viewport
threshold: 0.5, // AND 50% visible
})

Why rootMargin matters: Without rootMargin, lazy loading triggers exactly when an element enters the viewport, causing a visible loading delay. With rootMargin: '200px', loading starts 200px before the element scrolls into view, creating a seamless experience.

Use case: Lazy loading images

const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
// Load image when it enters viewport
img.src = img.dataset.src!
img.onload = () => img.classList.add("loaded")
// Stop observing this image
observer.unobserve(img)
}
})
},
{
// Start loading slightly before image enters viewport
rootMargin: "50px",
},
)
// Observe all images with data-src attribute
document.querySelectorAll("img[data-src]").forEach((img) => {
imageObserver.observe(img)
})

Use case: Infinite scroll

6 collapsed lines
function setupInfiniteScroll(loadMoreFn: () => Promise<void>) {
// Sentinel setup (collapsed)
const sentinel = document.createElement("div")
sentinel.id = "scroll-sentinel"
sentinel.style.height = "1px"
document.querySelector("#content-container")!.appendChild(sentinel)
let isLoading = false
// Key pattern: IntersectionObserver triggers load before reaching end
const observer = new IntersectionObserver(
async (entries) => {
const entry = entries[0]
if (entry.isIntersecting && !isLoading) {
isLoading = true
try {
await loadMoreFn()
} finally {
isLoading = false
}
}
},
{
rootMargin: "200px", // Trigger 200px before reaching sentinel
},
)
observer.observe(sentinel)
7 collapsed lines
return () => observer.disconnect()
}
// Usage (collapsed)
const cleanup = setupInfiniteScroll(async () => {
const newItems = await fetchNextPage()
renderItems(newItems)
})

Use case: Scroll-triggered animations

const animationObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("animate-in")
} else {
// Optional: reset animation when scrolling back up
entry.target.classList.remove("animate-in")
}
})
},
{
threshold: 0.1, // Trigger when 10% visible
},
)
// Observe all elements with animation class
document.querySelectorAll(".animate-on-scroll").forEach((element) => {
animationObserver.observe(element)
})

Performance benefit: IntersectionObserver uses browser’s rendering pipeline to detect intersections, avoiding expensive getBoundingClientRect() calls in scroll handlers. Per the W3C spec (Editor’s Draft, June 2024): “The information can be delivered asynchronously (e.g. from another thread) without penalty.”

IntersectionObserver implements privacy restrictions for cross-origin content to prevent viewport geometry probing:

// When observing cross-origin iframe content:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// For cross-origin targets:
// - rootBounds is null (suppressed to prevent viewport probing)
// - rootMargin effects are ignored
// - scrollMargin effects are ignored
if (entry.rootBounds === null) {
// Cross-origin target - limited information available
console.log("Cross-origin: rootBounds suppressed for privacy")
}
})
})

Design rationale: The spec states this “prevent[s] probing for global viewport geometry information that could deduce user hardware configuration” and avoids revealing whether cross-origin iframes are visible.

MutationObserver monitors DOM tree modifications, replacing legacy Mutation Events with better performance and clearer semantics. The DOM Standard (Section 4.3) defines three components: the MutationObserver interface, a “queuing a mutation record” algorithm, and the MutationRecord data structure. Unlike the older MutationEvent API which triggered synchronously for every change, MutationObserver batches mutations into a single callback at the end of a microtask.

Observed mutation types:

  • Child nodes: Elements added or removed from target
  • Attributes: Attribute values changed
  • Character data: Text node content changed
  • Subtree: Monitor target and all descendants
const observer = new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
// mutation.type - 'childList', 'attributes', or 'characterData'
// mutation.target - the node that changed
// mutation.addedNodes - NodeList of added children
// mutation.removedNodes - NodeList of removed children
// mutation.attributeName - changed attribute name
// mutation.oldValue - previous value (if requested in options)
})
})
observer.observe(targetNode, {
// At least one of these must be true:
childList: true, // Watch child node additions/removals
attributes: true, // Watch attribute changes
characterData: true, // Watch text content changes
// Optional refinements:
subtree: true, // Monitor all descendants too
attributeOldValue: true, // Include previous attribute values
characterDataOldValue: true, // Include previous text values
attributeFilter: ["class", "data-state"], // Only specified attributes
})

Use case: Monitoring dynamic content injection

function watchDynamicContent(container: Element, callback: (addedElements: Element[]) => void) {
const observer = new MutationObserver((mutations) => {
const addedElements: Element[] = []
// Key pattern: filter for element nodes from childList mutations
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
addedElements.push(node as Element)
}
})
}
})
if (addedElements.length > 0) {
callback(addedElements)
}
})
observer.observe(container, {
childList: true,
subtree: true, // Watch all descendants
})
return () => observer.disconnect()
}
9 collapsed lines
// Usage example (collapsed)
const cleanup = watchDynamicContent(document.body, (elements) => {
elements.forEach((el) => {
if (el.hasAttribute("data-tooltip")) {
initializeTooltip(el)
}
})
})

Use case: Form validation on attribute changes

function trackFormFieldState(form: HTMLFormElement) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Key pattern: watch aria-invalid attribute for validation state changes
if (mutation.type === "attributes" && mutation.attributeName === "aria-invalid") {
const field = mutation.target as HTMLElement
10 collapsed lines
// Error message visibility logic (collapsed)
const isInvalid = field.getAttribute("aria-invalid") === "true"
const errorId = field.getAttribute("aria-describedby")
if (errorId) {
const errorElement = document.getElementById(errorId)
if (errorElement) {
errorElement.hidden = !isInvalid
}
}
}
})
})
// Watch all form fields with attributeFilter for efficiency
form.querySelectorAll("input, textarea, select").forEach((field) => {
observer.observe(field, {
attributes: true,
attributeFilter: ["aria-invalid"], // Only watch this attribute
attributeOldValue: true,
})
})
return () => observer.disconnect()
}

Use case: Character data monitoring

function watchTextChanges(editableElement: HTMLElement) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "characterData") {
console.log("Text changed from:", mutation.oldValue)
console.log("Text changed to:", mutation.target.textContent)
// Update character count, word count, etc.
updateTextStats(editableElement)
}
})
})
observer.observe(editableElement, {
characterData: true,
subtree: true, // Watch text nodes in all descendants
characterDataOldValue: true,
})
return () => observer.disconnect()
}

Batching behavior: MutationObserver batches multiple mutations and delivers them asynchronously, improving performance compared to synchronous Mutation Events.

const target = document.getElementById("container")!
const observer = new MutationObserver((mutations) => {
// This callback receives ALL mutations in a single batch
console.log(`Received ${mutations.length} mutations`)
})
observer.observe(target, { childList: true })
// These three operations generate three mutation records,
// but callback is invoked once with all three
target.appendChild(document.createElement("div"))
target.appendChild(document.createElement("span"))
target.appendChild(document.createElement("p"))
// Callback invoked asynchronously with 3 mutation records

ResizeObserver reports changes to element dimensions, enabling responsive component sizing independent of viewport resize events. Defined in the CSS Resize Observer Module Level 1 specification (separate from DOM Standard).

Key distinction:

  • Content box: Inner content area (excludes padding and border)
  • Border box: Full element dimensions (includes padding and border)
  • Device pixel content box: Content box in device pixels (for high-DPI displays)
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
// entry.target - the observed element
// entry.contentRect - DOMRect with dimensions (legacy)
// entry.contentBoxSize - ReadonlyArray of ResizeObserverSize
// entry.borderBoxSize - ReadonlyArray of ResizeObserverSize
// entry.devicePixelContentBoxSize - Device pixel dimensions
})
})
observer.observe(element, {
box: "content-box", // 'content-box', 'border-box', or 'device-pixel-content-box'
})

ResizeObserverEntry structure:

interface ResizeObserverEntry {
target: Element
contentRect: DOMRectReadOnly // Legacy property
contentBoxSize: ReadonlyArray<ResizeObserverSize>
borderBoxSize: ReadonlyArray<ResizeObserverSize>
devicePixelContentBoxSize: ReadonlyArray<ResizeObserverSize>
}
interface ResizeObserverSize {
inlineSize: number // Width in horizontal writing mode
blockSize: number // Height in horizontal writing mode
}

Use case: Responsive typography

function setupResponsiveText(container: HTMLElement) {
const heading = container.querySelector("h1")!
const paragraph = container.querySelector("p")!
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
// Key pattern: use contentBoxSize for modern width detection
if (entry.contentBoxSize) {
const contentBoxSize = Array.isArray(entry.contentBoxSize) ? entry.contentBoxSize[0] : entry.contentBoxSize
const width = contentBoxSize.inlineSize
// Scale font size based on container width
heading.style.fontSize = `${Math.max(1.5, width / 200)}rem`
paragraph.style.fontSize = `${Math.max(1, width / 600)}rem`
} else {
// Fallback for older browsers (collapsed)
const width = entry.contentRect.width
7 collapsed lines
heading.style.fontSize = `${Math.max(1.5, width / 200)}rem`
paragraph.style.fontSize = `${Math.max(1, width / 600)}rem`
}
})
})
observer.observe(container)
return () => observer.disconnect()
}

Use case: Container-based grid layout

function setupAdaptiveGrid(grid: HTMLElement) {
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width
// Adjust grid columns based on available width
let columns: number
if (width < 400) columns = 1
else if (width < 800) columns = 2
else if (width < 1200) columns = 3
else columns = 4
grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`
grid.dataset.columns = String(columns)
})
})
observer.observe(grid)
return () => observer.disconnect()
}

Use case: Textarea auto-resize

function setupAutoResize(textarea: HTMLTextAreaElement) {
const observer = new ResizeObserver((entries) => {
// Prevent infinite loops by checking if size actually changed
entries.forEach((entry) => {
const target = entry.target as HTMLTextAreaElement
// Reset height to measure scrollHeight
target.style.height = "auto"
// Set height to content height
const newHeight = target.scrollHeight + 2 // +2 for border
target.style.height = `${newHeight}px`
})
})
// Observe the textarea
observer.observe(textarea)
// Also trigger on input
textarea.addEventListener("input", () => {
textarea.style.height = "auto"
textarea.style.height = `${textarea.scrollHeight + 2}px`
})
return () => observer.disconnect()
}

ResizeObserver processing occurs between layout and paint phases in the rendering pipeline. This timing makes the callback an ideal place for layout changes—modifications only invalidate layout, not paint, avoiding unnecessary repaints.

Edge cases to know:

  • Observations trigger when elements are inserted/removed from DOM
  • Setting display: none fires an observation
  • Non-replaced inline elements always report empty dimensions (no intrinsic size)
  • CSS transforms do NOT trigger observations—transforms don’t change box dimensions, only visual position

Avoiding infinite loops:

ResizeObserver can trigger infinite notification loops if your callback modifies observed element dimensions. The browser limits iterations and throws an error:

// ❌ Infinite loop: callback increases size, triggering more callbacks
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const target = entry.target as HTMLElement
// Each resize triggers another observation
target.style.width = `${entry.contentRect.width + 10}px`
})
})
observer.observe(element)
// Error: ResizeObserver loop completed with undelivered notifications

Solutions:

// ✅ Solution 1: Use requestAnimationFrame to defer changes
const observer = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
entries.forEach((entry) => {
const target = entry.target as HTMLElement
target.style.width = `${entry.contentRect.width + 10}px`
})
})
})
// ✅ Solution 2: Track expected size to avoid redundant updates
const expectedSizes = new WeakMap<Element, number>()
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const expectedSize = expectedSizes.get(entry.target)
const currentSize = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width
// Only update if not at expected size
if (currentSize !== expectedSize) {
const newSize = currentSize + 10
;(entry.target as HTMLElement).style.width = `${newSize}px`
expectedSizes.set(entry.target, newSize)
}
})
})

DOM Changes

Observer APIs

IntersectionObserver

MutationObserver

ResizeObserver

Viewport intersection

Tree mutations

Dimension changes

Async batch callback

Your code runs once

for multiple changes

Observer APIs batch changes and invoke callbacks asynchronously for optimal performance

Compared to traditional event listeners:

// ❌ Expensive: scroll handler runs on every scroll event
window.addEventListener("scroll", () => {
document.querySelectorAll(".lazy-image").forEach((img) => {
const rect = img.getBoundingClientRect()
if (rect.top < window.innerHeight) {
loadImage(img)
}
})
})
// ✅ Efficient: IntersectionObserver uses browser internals
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadImage(entry.target)
observer.unobserve(entry.target)
}
})
})
document.querySelectorAll(".lazy-image").forEach((img) => {
observer.observe(img)
})

Batching example:

// Demonstrate batching behavior
const element = document.getElementById("container")!
const observer = new ResizeObserver((entries) => {
console.log(`Callback invoked with ${entries.length} entries`)
console.log("All size changes processed in one batch")
})
observer.observe(element)
// Rapidly change size multiple times
element.style.width = "100px"
element.style.width = "200px"
element.style.width = "300px"
// Output (single callback invocation):
// "Callback invoked with 1 entries"
// "All size changes processed in one batch"

DOM APIs reflect decades of web platform evolution, balancing backward compatibility with modern performance requirements. The interface hierarchy separates universal tree operations (Element) from markup-specific behaviors (HTMLElement), enabling consistent APIs across HTML, SVG, and MathML. Selector return types encode liveness semantics directly into collection objects—live HTMLCollection for real-time tracking, static NodeList for one-time queries (with the notable exception of getElementsByName() returning a live NodeList). Observer APIs replace polling and event handler patterns with efficient, callback-based change detection integrated into the browser’s rendering pipeline.

Understanding these design decisions enables you to choose the right API for each scenario: Element-typed utilities for cross-markup compatibility, querySelectorAll() for one-time selections, getElementsByClassName() when tracking dynamic DOM state, and Observer APIs for monitoring changes without performance overhead. The specs themselves—particularly the WHATWG DOM Standard and W3C IntersectionObserver spec—provide the authoritative source for edge cases and guarantees when production behavior matters.


  • JavaScript fundamentals (classes, inheritance, async callbacks)
  • Basic HTML/CSS understanding (elements, attributes, selectors)
  • Familiarity with browser DevTools for DOM inspection
  • Interface hierarchy: EventTarget → Node → Element → HTMLElement; Element methods (querySelector(), classList) work across HTML, SVG, and MathML
  • Collection liveness: querySelectorAll() returns static NodeList (snapshot); getElementsByClassName() returns live HTMLCollection (auto-updates); getElementsByName() is an unusual exception returning live NodeList
  • Observer pattern: IntersectionObserver, MutationObserver, and ResizeObserver all use async batched callbacks for efficient change detection
  • IntersectionObserver privacy: Cross-origin targets have suppressed rootBounds and ignored margin options to prevent viewport probing
  • ResizeObserver timing: Callbacks execute between layout and paint—ideal for layout changes without extra repaints; CSS transforms don’t trigger observations

Specifications (Primary Sources)

Official Documentation

Supplementary Resources

Read more

  • Previous

    Fetch, Streams, and AbortController

    Web Foundations / Browser APIs 20 min read

    A comprehensive exploration of the modern web’s network primitives, examining how the Fetch Standard (WHATWG Living Standard, January 2026) unifies request/response handling across all platform features, how the Streams Standard enables incremental data processing with automatic backpressure, and how AbortController/AbortSignal (DOM Standard Section 3.3) provide composable cancellation semantics. These three APIs form an integrated system: Fetch exposes response bodies as ReadableStreams, Streams propagate backpressure through pipe chains, and AbortSignal enables cancellation at any point in the pipeline.

  • Next

    Accessibility Testing and Tooling Workflow

    Web Foundations / Accessibility Standards 14 min read

    A practical workflow for automated and manual accessibility testing, covering tool selection, CI/CD integration, and testing strategies. Automated testing catches approximately 57% of accessibility issues (Deque, 2021)—the remaining 43% requires keyboard navigation testing, screen reader verification, and subjective judgment about content quality. This guide covers how to build a testing strategy that maximizes automated coverage while establishing the manual testing practices that no tool can replace.