Skip to content

The DOM and the Browser

The DOM (Document Object Model) is the browser’s live object representation of an HTML page. None of it is part of the JavaScript language — it is a set of host APIs, specified by WHATWG, that the browser hands your code.

The browser parses HTML into a tree of nodes. Elements are nodes; so are text and comments.

<body>
<div id="app">
<h1 class="title">Hello</h1>
</div>
</body>
document
└── html
└── body
└── div#app
└── h1.title
└── #text "Hello"

The tree is live: changing it changes the rendered page immediately.

document.documentElement; // <html>
document.head;
document.body;
document.title; // readable and writable

Navigating between nodes — note that the element* versions skip text and comment nodes, which is almost always what you want:

const el = document.querySelector('#app');
el.parentElement;
el.children; // HTMLCollection of element children (live)
el.childNodes; // NodeList including text nodes (live)
el.firstElementChild;
el.lastElementChild;
el.nextElementSibling;
el.previousElementSibling;
el.closest('.container'); // nearest ancestor (or self) matching a selector
el.matches('.title'); // => boolean
el.contains(other); // => boolean
document.querySelector('.title'); // first match, or null
document.querySelectorAll('li.active'); // static NodeList of all matches
document.getElementById('app'); // fastest, id only, or null
element.querySelector('.child'); // scoped to a subtree

querySelector accepts any CSS selector, which covers nearly everything:

document.querySelector('#app > .title');
document.querySelector('input[type="email"]');
document.querySelector('li:nth-child(2)');
document.querySelectorAll('a[href^="https://"]');

querySelectorAll returns a NodeList, not an array. It has forEach and is iterable, but not map or filter:

const items = document.querySelectorAll('li');
items.forEach((li) => li.remove()); // works
[...items].map((li) => li.textContent); // spread for array methods
Array.from(items).filter((li) => li.dataset.active);
const el = document.querySelector('#app');
el.textContent; // all text, including hidden elements — safe
el.textContent = 'Hi'; // replaces children with a single text node
el.innerHTML; // the HTML string inside
el.innerHTML = '<b>Hi</b>'; // parses and replaces — dangerous with user data
el.innerText; // rendered text, respects CSS visibility, forces reflow
el.outerHTML; // includes the element itself
const li = document.createElement('li');
li.textContent = 'New item';
li.className = 'item';
li.dataset.id = '42'; // becomes data-id="42"
document.querySelector('ul').append(li);

Insertion methods:

parent.append(node, 'text', ...); // at the end; accepts multiple nodes and strings
parent.prepend(node); // at the start
node.before(newNode); // as a previous sibling
node.after(newNode); // as a next sibling
node.replaceWith(newNode);
node.remove(); // removes itself — no parent needed
parent.appendChild(node); // older API: one Node only, returns it
parent.insertBefore(node, ref);
parent.removeChild(node);

insertAdjacentHTML parses a string at a precise position without destroying existing children (and without the full re-parse that innerHTML += causes):

el.insertAdjacentHTML('beforeend', '<li>Item</li>');
// positions: 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'

Each DOM insertion can trigger layout work. A DocumentFragment is an off-screen container; appending it inserts all its children in one operation.

const frag = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement('li');
li.textContent = item.name;
frag.append(li);
}
document.querySelector('ul').append(frag); // one insertion

node.cloneNode(true) deep-copies a subtree — the basis of <template>-driven rendering:

<template id="row">
<li class="row"><span class="name"></span></li>
</template>
const tpl = document.querySelector('#row');
const clone = tpl.content.cloneNode(true);
clone.querySelector('.name').textContent = 'Ada';
list.append(clone);

An attribute is what is written in the HTML. A property is a field on the JavaScript object. They are related but not identical — attributes are always strings, properties can be any type, and for many of them the attribute only supplies the initial value.

<input id="email" type="email" value="a@b.com" data-role="primary">
const input = document.querySelector('#email');
input.getAttribute('value'); // => 'a@b.com' — the initial HTML value
input.value; // => whatever the user has typed now
input.value = 'new@x.com';
input.getAttribute('value'); // => 'a@b.com' — attribute unchanged
el.getAttribute('data-role'); // => 'primary'
el.setAttribute('disabled', ''); // boolean attributes: presence is what counts
el.removeAttribute('disabled');
el.hasAttribute('disabled'); // => false

Practical rules:

  • Use properties for state you read and write in JavaScript: value, checked, disabled, textContent.
  • Use attributes for custom data and ARIA: data-*, aria-*, role.
  • class is the attribute; className (or better, classList) is the property.
  • for is the attribute; htmlFor is the property.

data-* attributes get a typed accessor via dataset (kebab-case becomes camelCase):

<div data-user-id="42" data-active="true"></div>
el.dataset.userId; // => '42' (always a string)
el.dataset.active; // => 'true' (a string, not a boolean)
el.dataset.newField = 'x'; // adds data-new-field="x"
Number(el.dataset.userId); // => 42
el.classList.add('active', 'visible');
el.classList.remove('hidden');
el.classList.toggle('open'); // => true if now present
el.classList.toggle('open', isOpen); // force to a specific state
el.classList.contains('active'); // => true
el.classList.replace('old', 'new');
el.style.color = 'red';
el.style.backgroundColor = '#eee'; // camelCase for hyphenated properties
el.style.setProperty('--theme', 'dark'); // CSS custom properties need this form
getComputedStyle(el).fontSize; // => '16px' — the resolved value, read-only
const button = document.querySelector('#save');
button.addEventListener('click', (event) => {
console.log('clicked', event.target);
});

Removing a listener requires the same function reference, so an inline arrow can never be removed:

function onClick(e) { /* ... */ }
button.addEventListener('click', onClick);
button.removeEventListener('click', onClick); // works
button.addEventListener('click', () => {});
button.removeEventListener('click', () => {}); // does nothing — different function

Options:

el.addEventListener('click', fn, { once: true }); // auto-removes after one call
el.addEventListener('scroll', fn, { passive: true }); // promises no preventDefault — smoother scrolling
el.addEventListener('click', fn, { capture: true }); // run during the capture phase
el.addEventListener('click', fn, { signal: controller.signal }); // remove via AbortController

AbortController is the cleanest way to remove many listeners at once:

const controller = new AbortController();
const { signal } = controller;
el.addEventListener('mousemove', onMove, { signal });
window.addEventListener('resize', onResize, { signal });
controller.abort(); // removes both
el.addEventListener('click', (event) => {
event.type; // => 'click'
event.target; // the element that was actually clicked
event.currentTarget; // the element the listener is attached to
event.timeStamp;
event.preventDefault(); // cancel the default action
event.stopPropagation(); // stop bubbling to ancestors
});

target vs currentTarget is the distinction that makes delegation work: target is where the event originated, currentTarget is where this listener lives.

Event-specific fields:

// MouseEvent
event.clientX; event.clientY; // viewport coordinates
event.button; // 0 left, 1 middle, 2 right
event.shiftKey; event.ctrlKey; event.metaKey;
// KeyboardEvent
event.key; // => 'Enter', 'a', 'ArrowUp' — the character/name
event.code; // => 'KeyA' — the physical key, layout-independent
event.repeat; // => true while held down

An event travels in three phases:

  1. Capture — from window down to the target.
  2. Target — at the element itself.
  3. Bubble — back up from the target to window.

Listeners run in the bubble phase by default. Pass { capture: true } for the way down.

document.body.addEventListener('click', () => console.log('body (bubble)'));
document.body.addEventListener('click', () => console.log('body (capture)'), true);
button.addEventListener('click', () => console.log('button'));
// Clicking the button logs:
// body (capture)
// button
// body (bubble)

Most events bubble; focus, blur, load, and a few others do not. (focusin/focusout are the bubbling equivalents of focus/blur.)

event.stopPropagation() stops the journey; event.stopImmediatePropagation() also prevents other listeners on the same element from running.

Cancels the browser’s built-in behavior for that event.

form.addEventListener('submit', (e) => {
e.preventDefault(); // stop the page from navigating
submitViaFetch();
});
link.addEventListener('click', (e) => {
e.preventDefault(); // stop navigation
});

preventDefault() and stopPropagation() are independent: one blocks the default action, the other blocks travel through the tree.

Attach one listener to a container instead of one per child. It works because events bubble, and it automatically covers elements added later.

document.querySelector('#todo-list').addEventListener('click', (event) => {
const deleteBtn = event.target.closest('.delete');
if (!deleteBtn) return; // click was elsewhere
const item = deleteBtn.closest('li');
item.remove();
});

closest() is essential here: a click may land on an icon inside the button, so event.target is not necessarily the button.

Delegation means fewer listeners, less memory, no rebinding after re-rendering, and it handles dynamically inserted elements for free.

const event = new CustomEvent('item:added', {
detail: { id: 42 },
bubbles: true,
});
element.dispatchEvent(event);
document.addEventListener('item:added', (e) => console.log(e.detail.id)); // => 42
document.addEventListener('DOMContentLoaded', () => {
// HTML parsed and DOM built; images may still be loading
});
window.addEventListener('load', () => {
// everything, including images and stylesheets, has loaded
});
window.addEventListener('beforeunload', (e) => {
e.preventDefault(); // triggers the browser's "leave site?" prompt
});
<form id="signup">
<input name="email" type="email" required>
<input name="password" type="password" minlength="8">
<input name="newsletter" type="checkbox" checked>
<select name="plan"><option value="pro">Pro</option></select>
<button type="submit">Sign up</button>
</form>
const form = document.querySelector('#signup');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(form);
data.get('email'); // => 'a@b.com'
const values = Object.fromEntries(data); // => { email, password, plan, ... }
await fetch('/api/signup', { method: 'POST', body: data });
});

Field access and events:

form.elements.email; // by name
input.value; // current text
checkbox.checked; // boolean
select.value; // selected option's value
input.addEventListener('input', fn); // fires on every keystroke
input.addEventListener('change', fn); // fires on commit (blur, or selection change)
input.focus(); input.blur();

Built-in constraint validation, which you can drive from JavaScript:

input.checkValidity(); // => boolean
input.validity.tooShort; // specific failure flags
input.validationMessage; // the browser's message
input.setCustomValidity('Passwords do not match'); // '' clears it
form.reportValidity(); // show the browser's validation UI
async function loadUsers() {
const res = await fetch('/api/users', {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ada' }),
credentials: 'same-origin', // 'include' to send cookies cross-origin
});

Two browser-specific things beyond the general fetch behavior:

CORS. A cross-origin request succeeds only if the server sends permitting Access-Control-Allow-* headers. Without them, the browser rejects the promise and your code sees a generic network error — the detail is only in the console. CORS is enforced by the browser; it is not a server-side security boundary.

Cancellation.

const controller = new AbortController();
input.addEventListener('input', () => controller.abort()); // cancel a stale search
const res = await fetch(url, { signal: controller.signal });

localStorage and sessionStorage share the same API. Both are synchronous, string-only, per-origin, and limited to roughly 5–10 MB.

localStorage sessionStorage
Lifetime until explicitly cleared until the tab closes
Shared across tabs yes (same origin) no
localStorage.setItem('theme', 'dark');
localStorage.getItem('theme'); // => 'dark'
localStorage.getItem('missing'); // => null
localStorage.removeItem('theme');
localStorage.clear();
localStorage.length;

Everything is a string, so objects need JSON:

localStorage.setItem('prefs', JSON.stringify({ theme: 'dark', fontSize: 14 }));
function readPrefs() {
try {
return JSON.parse(localStorage.getItem('prefs')) ?? {};
} catch {
return {}; // corrupted or hand-edited data
}
}
localStorage.setItem('count', 5);
localStorage.getItem('count'); // => '5' — a string

The storage event fires in other tabs of the same origin, which is a simple cross-tab sync channel:

window.addEventListener('storage', (e) => {
if (e.key === 'theme') applyTheme(e.newValue);
});

For structured or large data, use IndexedDB — asynchronous, transactional, and capable of storing objects and blobs directly.

setTimeout/setInterval behave as described in async JavaScript, with browser-specific caveats: nested timers are clamped to about 4 ms, and background tabs throttle them aggressively (often to once per second or less).

For anything visual, use requestAnimationFrame, which runs right before the next paint and pauses entirely in background tabs:

function animate(timestamp) {
el.style.transform = `translateX(${(timestamp / 10) % 300}px)`;
requestAnimationFrame(animate);
}
const id = requestAnimationFrame(animate);
cancelAnimationFrame(id);

Throttling a scroll handler is the standard use:

let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateHeader(window.scrollY);
ticking = false;
});
}, { passive: true });
  • The DOM is a live tree of host objects, not part of the JavaScript language.
  • querySelector/querySelectorAll take any CSS selector; the NodeList is static and needs spreading for array methods.
  • textContent is safe; innerHTML with untrusted data is an XSS hole, and innerHTML += destroys listeners and state.
  • Attributes are the HTML strings; properties are the live JavaScript state. Use dataset for data-* and classList for classes.
  • Events capture down, hit the target, then bubble up. target is where it happened, currentTarget is where the listener is.
  • Delegate with one container listener plus event.target.closest(...); it survives re-renders and covers new elements.
  • Remove listeners with the same function reference, or { once: true } / an AbortController signal.
  • FormData plus Object.fromEntries reads a whole form; unchecked checkboxes are absent. Always re-validate on the server.
  • fetch needs res.ok checks and is subject to CORS; cancel with AbortController.
  • Web Storage is synchronous, string-only, per-origin, and never appropriate for secrets.
  • Use requestAnimationFrame for visual updates and IntersectionObserver for visibility.