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

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. Next Steps
  7. 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:

  1. Display a list of items loaded from a fetch() call to /api/items (mock the response with a Response object or a simple JSON server)
  2. Each item has a name and a delete button
  3. Clicking "Add" appends a new item via a POST fetch
  4. Clicking delete removes the item via a DELETE fetch
  5. Show loading state during fetches; show an error banner if a request fails
  6. Use event delegation on the list container (one listener, not one per item)
  7. Cancel any in-flight request when the component unmounts (use AbortController)
  8. Store the last-fetched items in sessionStorage so 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.

6. Next Steps

Sequenced links to dependent lessons, deeper dives, or production guides.

Now that you understand browser JavaScript fundamentals, explore these related topics:


Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse