Modern web applications — especially Single Page Applications (SPAs) and Progressive Web Apps — rely heavily on client-side storage. Whether it's caching API responses, saving a dark mode preference, or keeping a user logged in between sessions, you have three main choices: Cookies, LocalStorage, and IndexedDB.

Each mechanism exists for a different purpose. Using the wrong one can introduce subtle security vulnerabilities, performance bottlenecks, or data that vanishes at the worst possible time. This guide breaks down exactly how each one works, what it's designed for, and where developers commonly go wrong.

1. Cookies: The Old Guard

Cookies have been part of the web since 1994 — Netscape engineer Lou Montulli invented them to solve a stateless HTTP problem. They are small text files (4KB maximum) that the browser stores and automatically attaches to every HTTP request sent to your domain. That automatic sending is both their killer feature and their biggest liability.

Setting a cookie in JavaScript:

// Basic cookie — expires when the browser closes
document.cookie = "username=noah";

// Persistent cookie with expiry date
document.cookie = "theme=dark; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/";

// Secure, HttpOnly cookie (must be set server-side)
// Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict

The HttpOnly flag is the most important security attribute for auth cookies. When set, JavaScript cannot read the cookie at all — document.cookie will not include it. This makes your session tokens immune to Cross-Site Scripting (XSS) attacks, because even if an attacker injects a malicious script, it cannot exfiltrate the token.

The SameSite attribute protects against Cross-Site Request Forgery (CSRF) attacks by controlling when cookies are sent to cross-origin requests. Use Strict for maximum protection on authentication cookies, or Lax if your app needs cookies on top-level navigations from external links.

Best for: Session tokens, authentication state, CSRF tokens — anything that needs to be automatically sent to the server.

Avoid for: Storing large data. Every cookie is transmitted with every request, so a 4KB cookie adds 4KB of overhead to every image, API call, and page load to your domain. That compounds quickly.

2. LocalStorage: Simple & Synchronous

LocalStorage arrived with HTML5 and solved a simple problem: developers needed a way to store modest amounts of data client-side without the per-request overhead of cookies. It is a synchronous key-value store, available as a global localStorage object, with a capacity of roughly 5–10MB depending on the browser.

The core API:

// Write
localStorage.setItem('theme', 'dark');
localStorage.setItem('user', JSON.stringify({ id: 42, name: 'Noah' }));

// Read
const theme = localStorage.getItem('theme'); // "dark"
const user = JSON.parse(localStorage.getItem('user')); // { id: 42, name: 'Noah' }

// Delete one key
localStorage.removeItem('theme');

// Clear everything
localStorage.clear();

// Check how many keys are stored
console.log(localStorage.length);

LocalStorage only stores strings. That is the most common source of bugs: if you store a number, it comes back as a string. Always use JSON.stringify and JSON.parse when storing objects or arrays, and wrap reads in a try/catch — JSON.parse throws if the stored value is malformed.

function getStoredUser() {
    try {
        const raw = localStorage.getItem('user');
        return raw ? JSON.parse(raw) : null;
    } catch (e) {
        localStorage.removeItem('user'); // clean up corrupted data
        return null;
    }
}

The synchronous problem: Every call to getItem or setItem blocks the main thread. For small strings this is imperceptible, but if you store a large JSON object (tens of KB or more) and read it during page initialisation, you will stutter the UI. This is not theoretical — it is a documented source of Interaction to Next Paint regressions in production apps.

What about sessionStorage? It has an identical API to LocalStorage but data is scoped to a single browser tab and is cleared when the tab closes. Use it for temporary in-progress state — a multi-step form, a checkout flow — where you do not want data persisting across sessions or leaking between tabs.

Best for: UI preferences (theme, language, sidebar state), small configuration objects, remembering where a user left off in a workflow.

Avoid for: Sensitive data (no encryption, accessible to any script on the page), large objects, or anything that should sync across tabs in real time.

3. IndexedDB: The Heavy Lifter

IndexedDB is a full transactional database built into the browser. Unlike LocalStorage, it is asynchronous — reads and writes do not block the main thread. It supports indexes for fast lookups, can store binary data (images, files, ArrayBuffers), and has a practical storage limit measured in hundreds of megabytes or more depending on available disk space.

The raw IndexedDB API is notoriously verbose. Here is the minimal code to open a database, write a record, and read it back:

// Open (or create) a database named "myApp" at version 1
const request = indexedDB.open('myApp', 1);

request.onupgradeneeded = (event) => {
    const db = event.target.result;
    // Create an object store (like a table) with an auto-incrementing key
    db.createObjectStore('settings', { keyPath: 'key' });
};

request.onsuccess = (event) => {
    const db = event.target.result;

    // Write a record
    const tx = db.transaction('settings', 'readwrite');
    tx.objectStore('settings').put({ key: 'theme', value: 'dark' });

    // Read a record
    const readTx = db.transaction('settings', 'readonly');
    const getReq = readTx.objectStore('settings').get('theme');
    getReq.onsuccess = () => console.log(getReq.result); // { key: 'theme', value: 'dark' }
};

Most production apps use a wrapper library like idb (by Jake Archibald) to get a Promise-based API on top of IndexedDB without a heavy dependency. It exposes the full power of IndexedDB with a fraction of the boilerplate.

Best for: Offline-capable PWAs, caching API responses for network-independent use, storing user-generated content (images, documents), large structured datasets that need querying.

Avoid for: Simple key-value preferences (overkill), or anything that needs to be synchronously available at script startup.

Storage Limits by Browser

Browsers do not guarantee a fixed storage amount — they use a quota system based on available disk space. Here are the approximate practical limits:

Browser LocalStorage IndexedDB Notes
Chrome / Edge ~5MB Up to 60% of disk Uses Storage API quota
Firefox ~5–10MB Up to 50% of disk Prompts user above 50MB
Safari ~5MB ~1GB (desktop) 7-day ITP eviction on mobile
Mobile browsers ~2.5–5MB Varies widely Aggressively evicted under storage pressure

The Safari row deserves special attention. Safari's Intelligent Tracking Prevention (ITP) policy will delete all script-writable storage — including LocalStorage and IndexedDB — for a domain if the user has not visited it in 7 days. If your app is used occasionally on iPhone, do not rely on LocalStorage for anything that must survive between sessions.

Security: What You Should Never Store Client-Side

Both LocalStorage and IndexedDB are accessible to any JavaScript running on your page. A single XSS vulnerability — a malicious ad, a compromised npm package, an unsanitised user input — can exfiltrate everything stored there. For that reason, never store:

  • Authentication tokens (JWTs, OAuth tokens) — use HttpOnly cookies instead
  • Passwords or password hashes — never, under any circumstance
  • Credit card numbers or payment data
  • Personally identifiable information (email addresses, phone numbers, government IDs) unless absolutely necessary and with full awareness of the risk

The common counter-argument is "but I encrypt the data before storing it." The problem is that the encryption key also has to live somewhere in the browser, which means it is vulnerable to the same XSS attack. There is no safe way to store truly sensitive secrets in client-side storage.

Summary: Choosing the Right Tool

Storage Capacity Blocking Best For
Cookies 4KB No Auth tokens, server-read state
LocalStorage ~5MB Yes (sync) UI preferences, small config
sessionStorage ~5MB Yes (sync) Per-tab temporary state
IndexedDB >500MB No (async) Offline data, binary files, large datasets

Frequently Asked Questions

Should I use localStorage or sessionStorage?

Use sessionStorage when the data only makes sense for the current visit — a partially filled form, a step in a wizard, a scroll position within a modal. Use localStorage when you want the data to persist across sessions, like a user's theme preference or their tool configuration. When in doubt, ask: "Would it be confusing or annoying if this data was still here next week?" If yes, use sessionStorage.

What happens when a user clears their browser cache?

Clearing cache in most browsers (via Settings → Clear browsing data) will wipe LocalStorage, sessionStorage, IndexedDB, and non-HttpOnly cookies simultaneously. Design your app to degrade gracefully — if the stored preference is gone, fall back to a sensible default. Never assume stored data exists; always check for null on reads.

Can I share data between tabs with LocalStorage?

Yes. LocalStorage is shared across all tabs and windows on the same origin. You can even listen for changes made in other tabs using the storage event:

window.addEventListener('storage', (event) => {
    if (event.key === 'theme') {
        applyTheme(event.newValue); // sync theme change from another tab
    }
});

This only fires in tabs that did not make the change, making it useful for cross-tab synchronisation. sessionStorage, by contrast, is completely isolated per tab — even if you duplicate a tab, changes in one do not propagate to the other.

At ToolBit, we use LocalStorage to remember your preferences (like staying in "Hex" mode on the color picker, or your last regex flags) because it's fast, simple, and privacy-friendly. No data ever leaves your device, and if you clear your cache, the tools simply reset to their defaults — nothing breaks.