Browser JavaScript — DOM, Events, fetch
Status: Active Last Updated: 2026-09-01 Category: Web — Browser JS Prerequisites: TypeScript basics (or equivalent JavaScript knowledge) Tags: javascript, dom, events, fetch, localstorage, async
Summary
Browser JavaScript is the same language as Node/Bun, but it has different APIs: the DOM (Document Object Model), event handling, fetch for network requests, and browser storage. This article covers the patterns you'll use to make a web page interactive.
What You'll Learn
- DOM selection and manipulation
- Event handling and delegation
- fetch() for HTTP requests
- localStorage, sessionStorage, cookies
- Async/await in the browser
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
- Change Log
1. Architectural Overview & Core Schema
Foundational theory, system mechanics, and high-level design.
DOM Selection
The DOM (Document Object Model) is a tree-structured representation of your HTML document. Every tag becomes a node in this tree, and JavaScript can traverse, read, and modify any node. Understanding how to select nodes efficiently is the foundation of all browser interactivity.
The Old Ways (Still Work)
The DOM API provides several built-in methods for selecting elements. getElementById is the fastest and most precise — it looks up a single element by its unique id attribute. Since IDs must be unique in a valid HTML document, this is guaranteed to return at most one element (or null if nothing matches). This method is preferable when you have a stable, known element you need to access repeatedly, such as a form, a main container, or a status display. Browsers optimize this lookup internally using a hash map, so it runs in effectively constant time regardless of document size.
getElementsByClassName and getElementsByTagName return live HTMLCollections — array-like objects that update automatically when the DOM changes. If you add a new element with a matching class, it appears in the collection immediately. These methods are useful when you need to operate on a group of related elements, like styling all buttons in a toolbar or reading all list items in a menu. The trade-off is that HTMLCollections are not true arrays: they lack map, filter, and forEach unless you convert them first with Array.from() or the spread operator.
// Fastest: select by ID (constant time)
const header = document.getElementById("header");
if (header) {
console.log("Found header:", header.textContent);
}
// Select all elements with a class (returns live HTMLCollection)
const buttons = document.getElementsByClassName("button");
for (let i = 0; i < buttons.length; i++) {
buttons[i].disabled = true; // disable all buttons
}
// Select all elements of a tag type
const allLinks = document.getElementsByTagName("a");
console.log(`Page has ${allLinks.length} links`);
The Modern Way: querySelector
querySelector and querySelectorAll accept any CSS selector — the same syntax you use in stylesheets. This makes them extremely flexible because you can target elements by ID (#app), class (.card), attribute ([data-active]), tag (div), or complex combinations (div.card > p:first-child). querySelector returns the first matching element (or null), while querySelectorAll returns a static NodeList of all matches.
NodeLists are similar to arrays in that they have a length property and support forEach, entries, keys, and values. Unlike HTMLCollections, they are "static" — they represent the DOM state at the moment of selection and won't update if the DOM changes afterward. This predictability makes them safer for iteration, especially inside loops or callbacks where the DOM might be modified concurrently.
The selector engine (in modern browsers, usually a Rust-based engine like Blink's "style" module) parses the CSS selector and walks the DOM tree efficiently. Complex selectors are fast enough for daily use, but if you're selecting the same element repeatedly in a performance-critical animation loop, cache the reference in a variable rather than re-querying every frame.
// First <nav> element
const nav = document.querySelector("nav");
// First element with class "card" inside #main
const card = document.querySelector("#main .card");
// All external links (elements with class "external")
const externalLinks = document.querySelectorAll("a.external");
// Convert NodeList to Array for full array methods
const headings = Array.from(document.querySelectorAll("h1, h2, h3"));
const h1Count = headings.filter(h => h.tagName === "H1").length;
// Iterate with forEach (available on NodeList)
externalLinks.forEach(link => {
link.target = "_blank"; // open in new tab
});
Navigating the Tree
Once you have a node, you can walk the tree using parent, sibling, and child properties. element.children returns an HTMLCollection of child elements (ignoring text nodes and comments). element.childNodes returns all child nodes including text nodes. element.parentElement goes up one level. element.nextElementSibling and element.previousElementSibling navigate horizontally. These navigation properties are read-only — you cannot assign to them to move nodes around the tree; use appendChild, insertBefore, or remove for that.
const selected = document.querySelector(".selected");
// Navigate the tree
const parent = selected.parentElement;
const siblings = Array.from(parent.children);
const nextItem = selected.nextElementSibling;
const prevItem = selected.previousElementSibling;
// Check relationships
if (selected.matches(".highlighted")) {
console.log("Already highlighted");
}
2. Deep Dive & Implementation
Technical implementation, fully commented code blocks, and step-by-step logic.
Guided Checkpoint
Inspect DOM properties and verify event delegation in the browser console:
// Verify DOM structure
const main = document.querySelector('main');
console.log('main children:', main.children.length);
// Verify event delegation: click any element and check the event chain
document.addEventListener('click', (e) => {
console.log('Clicked:', e.target.tagName, 'under:', e.currentTarget.tagName);
});
// Inspect an element's computed styles
const el = document.querySelector('body > *');
console.log('display:', getComputedStyle(el).display);
DOM Manipulation
Once you've selected an element, you can change its content, attributes, styles, and position in the tree. The browser repaints and reflows the layout after each change, so batching multiple changes together produces better performance than making changes one at a time in a tight loop.
Text and HTML Content
textContent is the safest way to set or read text inside an element. It returns or sets exactly the text content, ignoring any HTML tags. It also correctly handles script tags and style elements — they are treated as text, not executed. This makes it immune to XSS (cross-site scripting) attacks when inserting user-provided content, as any HTML characters are automatically escaped.
innerHTML parses the string as HTML, which is powerful but dangerous with untrusted input. If you set innerHTML to a string containing user-submitted text, a malicious user could inject a <script> tag or an event handler like <img src=x onerror="...">. Always sanitize user input before passing it to innerHTML, or prefer textContent when you only need plain text. For building complex UI from scratch (not from user input), createElement and appendChild are safer and more performant for large structures because they create nodes individually without parsing a full HTML string.
const el = document.querySelector("#info");
// Safe: text only, escapes HTML characters
el.textContent = "<script>alert('xss')</script>";
// Output on page: <script>alert('xss')</script> (displayed as text, not executed)
// Dangerous with user input:
// el.innerHTML = userInput; // AVOID — XSS vulnerability
// Safer alternative for structured content:
const span = document.createElement("span");
span.textContent = userInput; // already escaped
el.appendChild(span);
// Reading content
console.log(el.textContent); // plain text
console.log(el.innerHTML); // raw HTML
Attributes and Data Attributes
Attributes live on the HTML element itself (the <input type="text" disabled> part). The setAttribute and getAttribute methods work with any attribute, but many common ones have shorthand properties: element.id, element.disabled, element.value, element.href (for anchors). Using the property directly is usually faster and more convenient when available.
data-* attributes store custom data on elements without affecting rendering or validity. JavaScript accesses them via element.dataset, which provides a camelCase view of the hyphenated attributes: data-user-id becomes element.dataset.userId. This is the standard way to attach metadata to DOM elements that you'll read later in event handlers or elsewhere in your script.
const input = document.querySelector("#username");
const card = document.querySelector(".card");
// Standard attributes via properties
input.id = "username-field";
input.placeholder = "Enter your name";
input.disabled = false;
// Custom data attributes via dataset
card.dataset.userId = "12345";
card.dataset.role = "admin";
// Reading back
console.log(card.dataset.userId); // "12345"
console.log(card.dataset.role); // "admin"
// Remove a data attribute
delete card.dataset.role;
// Class list manipulation (preferred over className string manipulation)
card.classList.add("featured", "highlighted");
card.classList.remove("featured");
card.classList.toggle("expanded", true); // force add
card.classList.toggle("expanded", false); // force remove
console.log(card.classList.contains("highlighted")); // true
Inline Styles and Computed Styles
Setting element.style.property adds or updates an inline style attribute. The property name uses camelCase: backgroundColor not background-color. This approach works well for one-off dynamic changes, but it doesn't reflect CSS cascade or external stylesheets — it only reads or writes the element's own style attribute.
To read the fully computed style (after CSS cascade, external stylesheets, and inline styles are all resolved), use getComputedStyle(element). This returns a CSSStyleDeclaration object with every property's resolved value. Note that this is read-only; you cannot set styles through it. For high-frequency updates like animation loops, inline styles are faster; for occasional reads (like checking an element's dimensions), getComputedStyle is fine.
const box = document.querySelector(".box");
// Inline style (single property)
box.style.color = "red";
box.style.backgroundColor = "#f0f0f0";
box.style.display = "flex";
box.style.gap = "1rem";
// Batch multiple styles at once (more efficient than individual assignments)
Object.assign(box.style, {
width: "200px",
height: "100px",
borderRadius: "8px",
padding: "16px"
});
// Read computed style
const computed = getComputedStyle(box);
console.log(computed.width); // e.g., "200px"
console.log(computed.display); // e.g., "flex"
// Read current dimensions including border/padding
const rect = box.getBoundingClientRect();
console.log(rect.width, rect.height, rect.top, rect.left);
Creating and Inserting Elements
Use document.createElement(tagName) to create a detached element, then populate it and attach it to the DOM. appendChild adds a node to the end of an element's child list. insertBefore(newNode, referenceNode) inserts before a specific child. prepend inserts at the beginning. remove and replaceWith detach or replace elements.
For building lists or grids from data, create elements in a loop and append them. For better performance when inserting many elements, build a DocumentFragment (an in-memory container), append all items to the fragment, then append the fragment to the DOM in one operation. This causes only one reflow instead of one per element.
const container = document.querySelector("#list-container");
const items = ["Alpha", "Beta", "Gamma"];
// One at a time (causes 3 reflows)
items.forEach(item => {
const li = document.createElement("li");
li.textContent = item;
container.appendChild(li);
});
// Better: use DocumentFragment (causes 1 reflow)
const fragment = document.createDocumentFragment();
items.forEach(item => {
const li = document.createElement("li");
li.textContent = item;
fragment.appendChild(li);
});
container.appendChild(fragment); // Single DOM insertion
// Insert before/first
const heading = document.createElement("h2");
heading.textContent = "Section Title";
container.insertBefore(heading, container.firstChild);
// Replace and remove
const oldCard = document.querySelector(".card.old");
const newCard = document.createElement("div");
newCard.className = "card new";
newCard.textContent = "Replaced content";
oldCard.replaceWith(newCard);
// oldCard.remove(); // Remove from DOM (modern browsers)
Events
Events are the browser's way of telling your code that something happened — a click, a keypress, a form submission, a network response arriving, a timer firing. JavaScript uses an "additive" event model: you register listener functions (callbacks) that the browser invokes when the event fires. Understanding this model deeply — including event phases, delegation, and the event object — is essential for writing interactive applications.
Registering and Removing Listeners
addEventListener(type, callback) attaches a listener without replacing any existing listeners on the same element and event type. This is different from properties like element.onclick, which overwrite each other. You can register multiple listeners for the same event, and they'll all fire in registration order. The callback receives an Event object as its first argument, which contains details about what happened.
Always pass a named function or an arrow function stored in a variable if you might need to remove the listener later. Anonymous functions cannot be removed because there's no reference to them. removeEventListener(type, callback) removes a specific listener — the callback reference must be identical to the one passed to addEventListener.
const button = document.querySelector("#submit-btn");
// Named function for later removal
function handleClick(event) {
console.log("Button clicked at:", event.clientX, event.clientY);
event.target.disabled = true; // prevent double-clicks
}
button.addEventListener("click", handleClick);
// Later: remove the listener (requires the same function reference)
button.removeEventListener("click", handleClick);
// Passive listeners improve scroll performance (browser doesn't wait for handler)
document.addEventListener("scroll", () => {
console.log("Scrolled");
}, { passive: true });
// Capture phase (fires during capture, not bubble)
// Useful for intercepting events on child elements before they reach the target
document.addEventListener("click", logClick, { capture: true });
The Event Object
The event object passed to every listener contains rich information about what happened. event.type is the event name (e.g., "click"). event.target is the element that originally triggered the event. event.currentTarget is the element the listener is attached to (useful inside delegated handlers). event.clientX and event.clientY are pointer coordinates relative to the viewport. event.preventDefault() cancels the browser's default behavior (following a link, submitting a form, scrolling). event.stopPropagation() stops the event from bubbling up to ancestor elements.
For keyboard events, event.key gives the string name of the pressed key ("Enter", "a", "ArrowUp"), while event.code gives the physical key location ("Enter", "KeyA", "ArrowUp"). Prefer event.key for text input handling and event.code for keyboard shortcut handling where the key's meaning shouldn't change with keyboard layout.
// Form submission
const form = document.querySelector("form");
form.addEventListener("submit", (event) => {
event.preventDefault(); // Stop full-page navigation
const formData = new FormData(form);
const email = formData.get("email");
const password = formData.get("password");
console.log("Submitting:", { email, password });
// Submit programmatically (AJAX)
fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
});
// Keyboard shortcuts
document.addEventListener("keydown", (event) => {
// Ctrl+S or Cmd+S to save
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault(); // Don't open save dialog
saveDocument();
}
// Escape to close modal
if (event.key === "Escape") {
closeModal();
}
});
// Pointer/touch events (unified API for mouse and touch)
document.addEventListener("pointermove", (event) => {
console.log(`Pointer at ${event.clientX}, ${event.clientY}`);
});
Event Delegation
Event delegation is a pattern where you attach a single listener to a parent element instead of many listeners to each child. When an event bubbles up from a child, the parent receives it and can inspect event.target to determine which child was clicked. This is dramatically more efficient for lists with many items, and it automatically handles dynamically added elements — you don't need to reattach listeners when the list grows.
The key is to check event.target (or walk up to event.target.closest(selector)) to confirm the click landed on the right element before taking action. Use event.target.matches(selector) or event.target.closest(selector) to test against a CSS selector. closest is particularly useful because it checks the target itself and then each ancestor up to the root, making it forgiving of nested structures.
// Instead of attaching a listener to every list item:
// BAD — creates N listeners, doesn't work for dynamically added items
document.querySelectorAll(".todo-item").forEach(item => {
item.addEventListener("click", () => toggleTodo(item.dataset.id));
});
// GOOD — single listener, works for all current and future items
const todoList = document.querySelector(".todo-list");
todoList.addEventListener("click", (event) => {
// closest() walks up from event.target to find a matching ancestor
const item = event.target.closest(".todo-item");
if (!item) return; // Click was not on a list item
const id = item.dataset.id;
// Check what was clicked inside the item
if (event.target.closest(".delete-btn")) {
deleteTodo(id);
} else if (event.target.closest(".toggle-btn")) {
toggleTodo(id);
}
});
Common Built-in Events
The browser fires dozens of event types. The most commonly used in interactive applications are: DOMContentLoaded fires when the HTML document is fully parsed (before images/stylesheets finish loading); load fires when everything including external resources is done; beforeunload fires when the user is about to navigate away (can be used to prompt for unsaved changes); resize fires on the window when the viewport size changes; scroll fires when an element is scrolled; input and change fire on form controls; error fires when a resource fails to load.
// DOM ready — use this instead of window.onload for most initialization
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM ready, initialize UI");
initializeApp();
});
// Full page load (images, stylesheets, iframes)
window.addEventListener("load", () => {
console.log("Everything loaded");
});
// Warn before leaving with unsaved changes
let hasUnsavedChanges = true;
window.addEventListener("beforeunload", (event) => {
if (hasUnsavedChanges) {
event.preventDefault();
// Modern browsers require a returnValue or return string
event.returnValue = "You have unsaved changes. Are you sure?";
}
});
// Responsive: react to viewport changes
window.addEventListener("resize", debounce(() => {
updateLayout();
}, 200));
// Intersection Observer (modern, performant alternative to scroll-based visibility)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target); // Stop watching once visible
}
});
}, { threshold: 0.1 });
document.querySelectorAll(".lazy-section").forEach(section => {
observer.observe(section);
});
fetch() and HTTP
The fetch() API is the modern browser interface for making HTTP requests. It replaces the older XMLHttpRequest with a cleaner, promise-based interface that integrates naturally with async/await. You can use it to load JSON data, submit forms, upload files, and communicate with REST or GraphQL APIs.
Basic Usage
A fetch() call takes a URL and optional configuration object, and returns a Promise that resolves to a Response object. The Response is not the data itself — it's metadata about the HTTP response (status, headers, etc.). To get the body, you call a method on the Response: response.json() for JSON, response.text() for plain text, response.blob() for binary data, response.formData() for form data, or response.arrayBuffer() for raw bytes. Each of these returns its own Promise, so you chain .then() calls or await them.
Always check response.ok (true for status 200-299) or the status code before processing the body. A 404 or 500 response from fetch() does not throw an error — the Promise only rejects on network failures (no internet, DNS failure, CORS blocked). This is a common mistake: fetch() fails network-side, not HTTP-side.
// Basic GET request
async function loadPosts() {
const response = await fetch("/api/posts");
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const posts = await response.json();
console.log("Posts:", posts);
return posts;
}
// POST with JSON body
async function createPost(data) {
const response = await fetch("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAuthToken()}`
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to create post");
}
return response.json();
}
// Using the Promise API (without async/await)
fetch("/api/posts")
.then(response => {
if (!response.ok) throw new Error(`Error: ${response.status}`);
return response.json();
})
.then(posts => {
renderPosts(posts);
})
.catch(error => {
console.error("Failed to load posts:", error);
showError("Could not load posts. Please try again.");
});
Error Handling Patterns
Robust fetch code handles three categories of failure: network errors (fetch itself rejects), HTTP errors (non-2xx status), and application errors (the server returned 200 but the JSON contains an error field). Use a helper function that normalizes all of these into a consistent result type.
A common pattern is to create an apiFetch wrapper that adds authentication headers, base URLs, error parsing, and timeout handling in one place. This keeps your component code clean and ensures consistent error handling across your entire application. Log errors in development but sanitize user-facing messages — never expose raw stack traces or internal paths.
// Wrapper that handles all error categories
async function apiFetch(endpoint, options = {}) {
const url = `/api${endpoint}`; // prepend base URL
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAuthToken()}`,
...options.headers
},
signal: controller.signal
});
clearTimeout(timeoutId);
// Parse response body (even for error status, to get error messages)
let data;
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
throw new ApiError(response.status, data);
}
return data;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
if (error instanceof ApiError) throw error;
throw new Error("Network error. Check your connection.");
}
}
class ApiError extends Error {
constructor(status, data) {
super(data.message || `HTTP ${status}`);
this.status = status;
this.data = data;
}
}
// Usage
async function loadUserProfile(userId) {
try {
const user = await apiFetch(`/users/${userId}`);
renderProfile(user);
} catch (error) {
showToast(error.message, "error");
}
}
Uploading Files and FormData
Use the FormData API to build request bodies for file uploads or multipart forms. Create a FormData object, append fields (strings or File/Blob objects), and pass it to fetch without a Content-Type header — the browser automatically sets the correct multipart/form-data boundary. For multiple file uploads, use input.files which is a FileList.
To track upload progress, use an XMLHttpRequest with an upload.progress event listener. The Fetch API itself does not currently support upload progress, though the Streams API can be used for more advanced streaming scenarios.
// File upload with progress tracking (requires XMLHttpRequest)
function uploadFile(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", file);
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("Authorization", `Bearer ${getAuthToken()}`);
xhr.send(formData);
});
}
// Usage
const fileInput = document.querySelector("#file-input");
fileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
const statusEl = document.querySelector("#upload-status");
try {
const result = await uploadFile(file, (percent) => {
statusEl.textContent = `Uploading... ${percent}%`;
});
statusEl.textContent = `Uploaded: ${result.filename}`;
} catch (error) {
statusEl.textContent = error.message;
}
});
Parallel Requests and Race Conditions
When you need data from multiple endpoints, use Promise.all() to fetch them in parallel rather than sequentially. This reduces total wait time — if one request takes 500ms and another takes 300ms, sequential fetching takes 800ms while parallel fetching takes only 500ms. Pass the results array to Promise.all() and destructure it to name each result.
Use Promise.race() to implement request timeout or to use the result of whichever request finishes first among several options. Use AbortController to cancel in-flight requests when the user navigates away or a new request replaces an old one — this prevents race conditions where a slow old request arrives after a faster new one and overwrites the UI with stale data.
// Parallel data fetching (reduces total wait time)
async function loadDashboard() {
const [user, posts, notifications] = await Promise.all([
apiFetch("/users/me"),
apiFetch("/posts?limit=10"),
apiFetch("/notifications?unread=true")
]);
renderUser(user);
renderPosts(posts);
renderNotifications(notifications);
}
// Cancel outdated requests (prevents race conditions)
let currentController = null;
async function search(query) {
// Cancel any in-flight search
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal
});
const data = await results.json();
renderResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Search failed:", error);
}
}
}
// Debounce input to avoid excessive requests
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
const searchInput = document.querySelector("#search");
searchInput.addEventListener("input", debounce((e) => {
search(e.target.value);
}, 300));
Storage
The browser provides several storage mechanisms with different capacities, lifetimes, and use cases. Choosing the right one depends on whether you need persistence across sessions, per-tab isolation, automatic expiry, or server-side access.
localStorage
localStorage stores data that persists even after the browser closes. Data is shared across all tabs and windows from the same origin (same protocol, domain, and port). Each origin gets 5-10 MB of storage. Data is stored as strings, so objects and arrays must be serialized with JSON.stringify() before saving and parsed with JSON.parse() after reading.
The API is synchronous, which means localStorage.setItem() and getItem() block the main thread. For large datasets or frequent reads, this can cause performance issues. In those cases, consider the asynchronous IndexedDB API or keep a cached variable in memory while periodically syncing to localStorage. Always handle the case where stored data is corrupted or missing — wrap reads in try/catch and fall back to defaults.
// Save a value (always strings)
localStorage.setItem("theme", "dark");
localStorage.setItem("lastVisit", new Date().toISOString());
// Save an object (must serialize)
const preferences = { fontSize: 16, compact: true, language: "en" };
localStorage.setItem("preferences", JSON.stringify(preferences));
// Read a value
const theme = localStorage.getItem("theme");
// Read and parse an object (with safe fallback)
let prefs;
try {
prefs = JSON.parse(localStorage.getItem("preferences") || "{}");
} catch {
prefs = {}; // Fallback if data is corrupted
}
// Remove and clear
localStorage.removeItem("theme"); // One key
localStorage.clear(); // All keys for this origin
// Check if storage is available (some private/incognito modes restrict it)
function isStorageAvailable(type) {
try {
const test = "__storage_test__";
window[type].setItem(test, test);
window[type].removeItem(test);
return true;
} catch {
return false;
}
}
// Storage event (fires in OTHER tabs/windows, not the current one)
window.addEventListener("storage", (event) => {
console.log("Storage changed:", event.key, event.newValue);
if (event.key === "theme") {
applyTheme(event.newValue);
}
});
sessionStorage
sessionStorage is identical to localStorage in API and storage limits, but the data is deleted when the tab or browser window closes. Each tab gets its own isolated sessionStorage — unlike localStorage, changes in one tab do not trigger the storage event in other tabs. This makes it ideal for sensitive per-session data like authentication tokens that shouldn't be accessible to other tabs, form drafts that only matter in the current session, or temporary UI state.
// Store session data (cleared on tab close)
sessionStorage.setItem("formDraft", JSON.stringify({ email: "test@example.com" }));
// Read session data
const draft = JSON.parse(sessionStorage.getItem("formDraft") || "{}");
// Auto-clear on session end (e.g., logout)
function logout() {
sessionStorage.clear();
window.location.href = "/login";
}
// Use sessionStorage for multi-step form wizard state
let wizardStep = parseInt(sessionStorage.getItem("wizardStep") || "0");
function nextStep() {
wizardStep++;
sessionStorage.setItem("wizardStep", wizardStep.toString());
renderStep(wizardStep);
}
Cookies
Cookies are the oldest browser storage mechanism and the only one that gets sent to the server automatically with every HTTP request. They are designed for server-side consumption, not client-side storage. Each cookie can hold up to 4 KB, and browsers typically limit total cookies per domain to around 150. Use them for session identifiers, authentication tokens (especially with the HttpOnly flag which prevents JavaScript access), and server-side preferences.
From JavaScript, you can read cookies via document.cookie, which returns a string like "token=abc123; theme=dark". Parsing this manually is error-prone — use a helper function or a library. Set cookies with document.cookie = "key=value; path=/; max-age=3600" (max-age in seconds, or use expires for a date). The Secure flag restricts the cookie to HTTPS. The SameSite flag controls cross-origin request behavior (Strict, Lax, or None).
// Read all cookies (as a string)
console.log(document.cookie); // "token=abc123; theme=dark"
// Set a cookie (doesn't overwrite other cookies — appends)
document.cookie = "theme=dark; path=/; max-age=86400"; // 1 day
document.cookie = "preferences=compact; path=/; max-age=604800"; // 7 days
// Set cookie for specific domain
document.cookie = "analytics=true; path=/; domain=example.com";
// Delete a cookie (set max-age=0)
document.cookie = "theme=; path=/; max-age=0";
// Cookie helper functions
function getCookie(name) {
const match = document.cookie.match(
new RegExp("(^| )" + name + "=([^;]+)")
);
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; expires=${expires}; SameSite=Lax`;
}
function deleteCookie(name) {
document.cookie = `${name}=; path=/; max-age=0`;
}
// Auth token pattern
function setAuthToken(token) {
// HttpOnly cookies must be set by the server, not JS
// For JS-accessible tokens (less secure):
setCookie("auth_token", token, 30);
}
function getAuthToken() {
return getCookie("auth_token");
}
3. Anti-Patterns & Common Pitfalls
Documented failure modes and how to detect/prevent them.
Not cleaning up event listeners (memory leaks)
Every addEventListener call registers a callback. If you add listeners inside a component that gets recreated (single-page app navigation, dynamic content) without calling removeEventListener, the old callbacks stay in memory and may fire on detached DOM nodes. This causes memory leaks and unpredictable behavior.
Detection: Chrome DevTools Memory tab — take a heap snapshot, filter by "Detached" to find DOM nodes still referenced by orphaned JavaScript. The Performance tab's "Memory" recording also shows heap growth if listeners accumulate.
Fix: Always remove listeners when the component unmounts. Use { once: true } for one-time handlers, or store references and call removeEventListener in a cleanup function:
function setupHandler(el) {
const handler = () => console.log('clicked');
el.addEventListener('click', handler);
return () => el.removeEventListener('click', handler); // cleanup function
}
// In a component lifecycle:
const cleanup = setupHandler(button);
// Later, when tearing down:
cleanup();
Using innerHTML with untrusted input (XSS)
el.innerHTML = userInput parses userInput as HTML, executing any <script> tags or inline event handlers. Even if userInput "looks safe," a malicious value like <img src=x onerror=alert(1)> runs immediately. Stored XSS persists across page loads.
Fix: Never pass user-supplied strings to innerHTML. Use textContent for plain text, or a sanitization library (DOMPurify) for HTML that genuinely needs rendering:
// Safe for plain text
span.textContent = userInput; // HTML chars auto-escaped
// Safe for HTML (sanitized)
div.innerHTML = DOMPurify.sanitize(userInput);
Storing sensitive data in localStorage
localStorage is accessible to any JavaScript on the same origin — XSS scripts can read tokens, emails, or PII stored there. localStorage is also not encrypted on disk (browsers store it in plaintext files). Sensitive auth tokens belong in httpOnly cookies (server-set) or in memory (cleared on page unload).
Fix: Store tokens in httpOnly; Secure; SameSite=Lax cookies (set by the server). If using localStorage for non-sensitive preferences, that's fine. Never store passwords, full PII, or raw JWTs in localStorage.
Missing event.preventDefault() on form submit
<form> submit events trigger a full-page POST by default. If you're handling the form with fetch(), failing to call event.preventDefault() causes a full-page reload that destroys client-side state and sends duplicate requests.
Fix: Always call preventDefault() on submit events when handling them client-side:
form.addEventListener('submit', (e) => {
e.preventDefault(); // Stop default navigation
// ... handle with fetch
});
DOM pollution: modifying the same element repeatedly in a loop
Appending or replacing DOM nodes one at a time inside a loop causes a reflow and repaint per iteration. A 100-item list appended with 100 DOM calls is 100x slower than building it with a DocumentFragment.
Fix:
// BAD — N reflows
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
container.appendChild(li); // reflow
});
// GOOD — 1 reflow
const fragment = document.createDocumentFragment();
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
fragment.appendChild(li);
});
container.appendChild(fragment); // single reflow
Forgetting to handle AbortError in fetch
When you cancel a fetch with AbortController.signal, the promise rejects with a DOMException named "AbortError". If you don't catch it, it surfaces as an unhandled promise rejection. This happens when a user navigates away before a request completes, or when a new search replaces an old in-flight request.
Fix: Check the error name:
try {
const data = await fetch(url, { signal });
} catch (err) {
if (err.name === 'AbortError') return; // Cancelled — not an error
throw err; // Propagate real errors
}
4. Independent Challenge
A problem to solve without step-by-step guidance; promotes synthesis.
Build a mini CRUD list manager (no frameworks, no innerHTML). Requirements:
- Display a list of items loaded from a
fetch()call to/api/items(mock the response with aResponseobject or a simple JSON server) - Each item has a name and a delete button
- Clicking "Add" appends a new item via a
POSTfetch - Clicking delete removes the item via a
DELETEfetch - Show loading state during fetches; show an error banner if a request fails
- Use event delegation on the list container (one listener, not one per item)
- Cancel any in-flight request when the component unmounts (use
AbortController) - Store the last-fetched items in
sessionStorageso a page refresh restores the list
Constraints: No innerHTML. No frameworks. Pure browser JS. Handle AbortError gracefully.
5. Consolidation & Key Invariants
Bullet-list summary of the must-remember rules.
- Remove event listeners when their target is destroyed — use
removeEventListeneror{ once: true }; orphaned listeners cause memory leaks. textContentfor plain text;innerHTMLonly with sanitization — user input passed toinnerHTMLis an XSS vulnerability.fetch()does not reject on HTTP errors — always checkresp.okorresp.statusbefore parsing the body.- Handle
AbortErrorin every fetch with a cancellation signal — silently swallowing it prevents unhandled promise rejections. - Batch DOM mutations with
DocumentFragment— appending one node at a time causes N reflows; one fragment append causes 1. - Store tokens in
httpOnlycookies, notlocalStorage—localStorageis readable by XSS scripts and unencrypted on disk. event.preventDefault()on form submit — without it, the browser performs a full-page POST and destroys SPA state.
6. Next Steps
Sequenced links to dependent lessons, deeper dives, or production guides.
Now that you understand browser JavaScript fundamentals, explore these related topics:
- Async Patterns: Dive deeper into Promises, async/await, and concurrent request patterns with
Promise.allSettled()andAbortController. - Web Components: Learn the browser's native component model using Custom Elements, Shadow DOM, and HTML Templates for reusable, encapsulated UI elements.
- Service Workers: Build offline-capable PWAs with service workers that intercept network requests and cache assets for reliable performance.
- Canvas and WebGL: For graphics, animations, and game development, the Canvas 2D API and WebGL provide hardware-accelerated rendering.
- TypeScript: Add static type safety to your browser code to catch errors at compile time and get better IDE support.
- Testing: Write unit tests with Vitest or Jest and integration tests with Playwright to verify your DOM logic and event handlers work correctly.
Change Log
- 2026-08-29: Migrated to 6-section canonical template (added sections 3, 4, 5; updated ToC; added Change Log entry)
- 2026-09-01: Expanded DOM Selection with tree navigation, querySelector deep dive, and performance notes. Expanded DOM Manipulation with DocumentFragment patterns and getBoundingClientRect. Expanded Events with keyboard shortcuts, passive listeners, and Intersection Observer. Expanded fetch() with timeout handling, parallel requests, and AbortController race condition prevention. Added full Storage section with localStorage, sessionStorage, and cookies including helper functions. Added Next Steps section. Promoted from stub to full lesson.
- 2026-08-29: Initial stub created (placeholder).