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 DOM tree
Section titled “The DOM tree”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 writableNavigating 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 selectorel.matches('.title'); // => booleanel.contains(other); // => booleanSelecting elements
Section titled “Selecting elements”document.querySelector('.title'); // first match, or nulldocument.querySelectorAll('li.active'); // static NodeList of all matchesdocument.getElementById('app'); // fastest, id only, or null
element.querySelector('.child'); // scoped to a subtreequerySelector 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 methodsArray.from(items).filter((li) => li.dataset.active);Reading and writing content
Section titled “Reading and writing content”const el = document.querySelector('#app');
el.textContent; // all text, including hidden elements — safeel.textContent = 'Hi'; // replaces children with a single text node
el.innerHTML; // the HTML string insideel.innerHTML = '<b>Hi</b>'; // parses and replaces — dangerous with user data
el.innerText; // rendered text, respects CSS visibility, forces reflowel.outerHTML; // includes the element itselfCreating, inserting, and removing nodes
Section titled “Creating, inserting, and removing nodes”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 stringsparent.prepend(node); // at the startnode.before(newNode); // as a previous siblingnode.after(newNode); // as a next siblingnode.replaceWith(newNode);node.remove(); // removes itself — no parent needed
parent.appendChild(node); // older API: one Node only, returns itparent.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'Batching with a fragment
Section titled “Batching with a fragment”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 insertionnode.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);Attributes vs properties
Section titled “Attributes vs properties”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 valueinput.value; // => whatever the user has typed now
input.value = 'new@x.com';input.getAttribute('value'); // => 'a@b.com' — attribute unchangedel.getAttribute('data-role'); // => 'primary'el.setAttribute('disabled', ''); // boolean attributes: presence is what countsel.removeAttribute('disabled');el.hasAttribute('disabled'); // => falsePractical 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. classis the attribute;className(or better,classList) is the property.foris the attribute;htmlForis 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); // => 42classList and styles
Section titled “classList and styles”el.classList.add('active', 'visible');el.classList.remove('hidden');el.classList.toggle('open'); // => true if now presentel.classList.toggle('open', isOpen); // force to a specific stateel.classList.contains('active'); // => trueel.classList.replace('old', 'new');el.style.color = 'red';el.style.backgroundColor = '#eee'; // camelCase for hyphenated propertiesel.style.setProperty('--theme', 'dark'); // CSS custom properties need this form
getComputedStyle(el).fontSize; // => '16px' — the resolved value, read-onlyEvents
Section titled “Events”Listening
Section titled “Listening”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 functionOptions:
el.addEventListener('click', fn, { once: true }); // auto-removes after one callel.addEventListener('scroll', fn, { passive: true }); // promises no preventDefault — smoother scrollingel.addEventListener('click', fn, { capture: true }); // run during the capture phaseel.addEventListener('click', fn, { signal: controller.signal }); // remove via AbortControllerAbortController 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 bothThe event object
Section titled “The event object”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:
// MouseEventevent.clientX; event.clientY; // viewport coordinatesevent.button; // 0 left, 1 middle, 2 rightevent.shiftKey; event.ctrlKey; event.metaKey;
// KeyboardEventevent.key; // => 'Enter', 'a', 'ArrowUp' — the character/nameevent.code; // => 'KeyA' — the physical key, layout-independentevent.repeat; // => true while held downBubbling and capturing
Section titled “Bubbling and capturing”An event travels in three phases:
- Capture — from
windowdown to the target. - Target — at the element itself.
- 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.
preventDefault
Section titled “preventDefault”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.
Event delegation
Section titled “Event delegation”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.
Dispatching your own events
Section titled “Dispatching your own events”const event = new CustomEvent('item:added', { detail: { id: 42 }, bubbles: true,});element.dispatchEvent(event);
document.addEventListener('item:added', (e) => console.log(e.detail.id)); // => 42Page lifecycle events
Section titled “Page lifecycle events”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 nameinput.value; // current textcheckbox.checked; // booleanselect.value; // selected option's value
input.addEventListener('input', fn); // fires on every keystrokeinput.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(); // => booleaninput.validity.tooShort; // specific failure flagsinput.validationMessage; // the browser's messageinput.setCustomValidity('Passwords do not match'); // '' clears itform.reportValidity(); // show the browser's validation UIfetch in the browser
Section titled “fetch in the browser”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 });Storage
Section titled “Storage”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'); // => nulllocalStorage.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 stringThe 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.
Timers and animation
Section titled “Timers and animation”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 });Key points
Section titled “Key points”- The DOM is a live tree of host objects, not part of the JavaScript language.
querySelector/querySelectorAlltake any CSS selector; the NodeList is static and needs spreading for array methods.textContentis safe;innerHTMLwith untrusted data is an XSS hole, andinnerHTML +=destroys listeners and state.- Attributes are the HTML strings; properties are the live JavaScript state. Use
datasetfordata-*andclassListfor classes. - Events capture down, hit the target, then bubble up.
targetis where it happened,currentTargetis 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 }/ anAbortControllersignal. FormDataplusObject.fromEntriesreads a whole form; unchecked checkboxes are absent. Always re-validate on the server.fetchneedsres.okchecks and is subject to CORS; cancel withAbortController.- Web Storage is synchronous, string-only, per-origin, and never appropriate for secrets.
- Use
requestAnimationFramefor visual updates andIntersectionObserverfor visibility.