9 min read

The Complete Guide to Browser Storage

Learn where and how to store user data in browsers. From cookies to IndexedDB, discover which storage method fits your use case.

June 14, 2026
Browser storage mechanisms displaying data organization and persistence layers

The Complete Guide to Browser Storage: Every Type Explained

Every web app needs to remember things. User preferences, form drafts, authentication tokens, offline data. But where do you actually store that stuff in the browser? And more importantly—where should you?

If you've ever wondered why cookies feel different from localStorage, or why IndexedDB exists when you've got LocalStorage, or what the hell Service Workers are doing with your data—this guide has the answers.

I'm going to break down every storage mechanism the browser offers, show you exactly how they work, and tell you when (and when not) to use each one.

Cookies: The Original, Still Relevant

Cookies are the granddaddy of browser storage. Created in 1994, they're the only storage type that automatically travels to your server with every HTTP request. That's their whole point.

The basics:

  • 4KB per cookie (tiny by modern standards)
  • Automatically sent in HTTP headers
  • Server can read them, JavaScript can read them (unless HttpOnly flag is set)
  • Can expire automatically or persist indefinitely
  • Cross-tab and cross-window aware (same domain)
// Setting a cookie
document.cookie = "username=John; expires=Fri, 31 Dec 2024 23:59:59 GMT; path=/; SameSite=Strict";
 
// Reading cookies
const username = document.cookie; // Returns "username=John; theme=dark"
 
// Deleting
document.cookie = "username=; expires=Thu, 01 Jan 1970 00:00:00 GMT";

When to use:

  • Authentication tokens (always with HttpOnly; Secure; SameSite=Strict)
  • Server-side session identification
  • User consent/privacy preferences (with consent)

When NOT to use:

  • Large data (4KB is tiny)
  • Client-only data (use LocalStorage instead)
  • Sensitive data without proper flags

The security model matters here. A properly configured auth cookie looks like:

Set-Cookie: sessionId=abc123; 
  HttpOnly;           // JavaScript can't access (prevents XSS theft)
  Secure;             // HTTPS only
  SameSite=Strict;    // No cross-site requests
  Path=/;             // Available to whole domain
  Max-Age=3600        // 1 hour expiry

Without these flags, cookies are a security nightmare. With them, they're the right choice for authentication.

LocalStorage: The Simple Persistent Cache

LocalStorage is what most developers reach for when they need to remember something across sessions. It's simple, synchronous, and persists until explicitly deleted.

The essentials:

  • 5-10MB per domain (browser dependent)
  • Persists until you manually clear it
  • Same-origin only (protocol + domain + port)
  • Cross-tab aware (changes in one tab trigger storage events in others)
  • Server cannot access it (no automatic HTTP transmission)
  • Synchronous API (blocks if large)
// Set and get
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme');
 
// Store objects (must stringify)
const user = { name: 'John', age: 30 };
localStorage.setItem('user', JSON.stringify(user));
const stored = JSON.parse(localStorage.getItem('user'));
 
// Clear everything
localStorage.clear();
 
// Listen for changes from other tabs
window.addEventListener('storage', (e) => {
  if(e.key === 'theme') {
    console.log(`Theme changed from ${e.oldValue} to ${e.newValue}`);
    applyTheme(e.newValue);
  }
});

When to use:

  • User preferences (theme, language, UI settings)
  • Offline data caches
  • Shopping carts (non-sensitive)
  • Form autosave/drafts
  • App configuration

When NOT to use:

  • Authentication tokens (use secure cookies instead—XSS can steal from localStorage)
  • Sensitive personal data
  • Large data (use IndexedDB for >1MB)

The biggest pitfall with localStorage: developers think it's secure. It's not. Any JavaScript on your page (including injected malicious scripts via XSS) can read everything. That's why tokens belong in HttpOnly cookies, not localStorage.

SessionStorage: One Tab, One Session

SessionStorage is like LocalStorage's temporary sibling. Same API, same 5-10MB capacity, but cleared the moment the tab closes.

Key differences from LocalStorage:

  • Cleared on tab close (not just browser close)
  • NOT shared across tabs
  • Same-origin only
  • No storage event across tabs (since it doesn't cross tabs)
  • Perfect for temporary, tab-specific data
// Multi-step form wizard
sessionStorage.setItem('wizard-step', '3');
sessionStorage.setItem('formData', JSON.stringify(data));
 
// On page refresh, wizard state survives
const step = sessionStorage.getItem('wizard-step');
 
// On tab close, everything vanishes

When to use:

  • Multi-step form wizards
  • Temporary authentication tokens (before sending to server)
  • Single-tab workflows
  • Sensitive data that auto-clears
  • Page-to-page navigation state

When NOT to use:

  • Data that needs to persist across sessions
  • Cross-tab communication (use BroadcastChannel API instead)
  • Long-term caching

SessionStorage is underrated. It's perfect for keeping sensitive temporary data that you don't want lingering after the user closes the tab.

IndexedDB: Real Database in Your Browser

IndexedDB is where things get serious. It's an actual embedded database—asynchronous, queryable, structured, with transactions and indexes. It's overkill for simple preferences, but essential for offline apps and large datasets.

What makes it powerful:

  • 50MB+ capacity (quota managed by browser)
  • Asynchronous (non-blocking)
  • Structured queries with indexes
  • Transactions (atomicity)
  • Supports complex objects (not just strings)
  • Can store files/blobs

Basic setup:

// Open or create database
const request = indexedDB.open('myApp', 1);
 
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  
  // Create an object store (like a table)
  if(!db.objectStoreNames.contains('users')) {
    const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
    
    // Create indexes for queries
    store.createIndex('email', 'email', { unique: true });
    store.createIndex('name', 'name');
  }
};
 
request.onsuccess = () => {
  const db = request.result;
  // Now use db...
};

CRUD operations:

// CREATE
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
store.add({ name: 'John', email: '[email protected]' });
 
// READ
store.get(1).onsuccess = (e) => console.log(e.target.result);
 
// Query by index
store.index('email').get('[email protected]').onsuccess = (e) => {
  console.log('Found:', e.target.result);
};
 
// UPDATE
const getReq = store.get(1);
getReq.onsuccess = () => {
  const user = getReq.result;
  user.name = 'Jane';
  store.put(user);
};
 
// DELETE
store.delete(1);
 
// Cursor (iterate all)
const cursor = store.openCursor();
cursor.onsuccess = (e) => {
  const c = e.target.result;
  if(c) {
    console.log(c.value);
    c.continue();
  }
};

The callback-heavy syntax is painful. Use async wrappers:

async function addUser(user) {
  const db = await openDB();
  const tx = db.transaction('users', 'readwrite');
  return new Promise((resolve, reject) => {
    const req = tx.objectStore('users').add(user);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
 
await addUser({ name: 'John' });

When to use:

  • Offline-first applications
  • Large cached datasets
  • Complex queries/filtering
  • Caching API responses
  • Full-text search implementations
  • File/blob storage

When NOT to use:

  • Simple preferences (use LocalStorage)
  • Data needed by server (sync required)
  • Synchronous access only (IndexedDB is async)

Cache API & Service Workers: Offline Superpowers

Cache API and Service Workers go together. Service Workers intercept network requests, and Cache API stores responses. Together, they enable true offline functionality.

Cache API basics:

// In a Service Worker
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/styles.css',
        '/app.js',
        '/manifest.json'
      ]);
    })
  );
});
 
// Fetch: try cache first, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
      .catch(() => caches.match('/offline.html'))
  );
});

Service Worker features:

  • Runs in background (survives page close)
  • Can intercept all network requests
  • Can trigger background sync
  • Can push notifications
  • Persists across sessions
// Cache versioning (manage multiple caches)
const CACHE_VERSION = 'v1';
 
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(name => {
          if(name !== CACHE_VERSION) {
            return caches.delete(name);
          }
        })
      );
    })
  );
});
 
// Store responses dynamically
fetch('/api/data')
  .then(response => {
    if(response.ok) {
      caches.open('api-cache').then(cache => {
        cache.put('/api/data', response.clone());
      });
    }
    return response;
  });

When to use:

  • Offline support
  • Network-independent performance
  • Asset caching strategies
  • Progressive Web Apps (PWAs)

Memory (JavaScript Variables): Temporary Working Space

Sometimes you just need a variable. JavaScript objects in memory are fast, synchronous, and perfect for runtime state.

// Simple but fragile
window.appState = { user: null, theme: 'light' };
 
// Better: closure pattern
const AppState = (() => {
  const state = { user: null, theme: 'light' };
  
  return {
    getState: () => ({ ...state }),
    setState: (updates) => Object.assign(state, updates),
    getUser: () => state.user,
    setUser: (user) => { state.user = user; }
  };
})();
 
// Modern: class with listeners
class StateManager {
  #state = {};
  #listeners = [];
  
  subscribe(listener) {
    this.#listeners.push(listener);
    return () => {
      this.#listeners = this.#listeners.filter(l => l !== listener);
    };
  }
  
  setState(updates) {
    this.#state = { ...this.#state, ...updates };
    this.#listeners.forEach(l => l(this.#state));
  }
}

When to use:

  • Component state
  • Runtime caches
  • Computed values
  • Application state (before sending to server)

When NOT to use:

  • Data needing persistence
  • Sensitive data (visible in DevTools)
  • After page reload

Decision Matrix: Pick the Right Storage

Size?

< 5KB: Cookies, LocalStorage, SessionStorage
5KB - 10MB: LocalStorage, SessionStorage, IndexedDB
> 10MB: IndexedDB, Cache API

Persistence?

Forever: LocalStorage, IndexedDB, Cookies (with expiry)
This session: SessionStorage
This tab only: SessionStorage, Memory
Cross-tab: LocalStorage, Cookies

Server needs to know?

Yes: Cookies (only option)
No: Everything else

Complex queries?

Simple key-value: Cookies, LocalStorage, SessionStorage
Advanced filtering: IndexedDB

Offline?

Required: Cache API + Service Worker + IndexedDB

Real-World Example: A Solid Storage Strategy

class Storage {
  constructor() {
    this.memory = {};
    this.db = null;
  }
  
  // Session-only: form state, temporary tokens
  setSession(key, value) {
    sessionStorage.setItem(key, JSON.stringify(value));
  }
  
  getSession(key) {
    const data = sessionStorage.getItem(key);
    return data ? JSON.parse(data) : null;
  }
  
  // Persistent user prefs: theme, language, UI state
  setPreference(key, value) {
    localStorage.setItem(`pref_${key}`, JSON.stringify(value));
  }
  
  getPreference(key) {
    const data = localStorage.getItem(`pref_${key}`);
    return data ? JSON.parse(data) : null;
  }
  
  // Large structured data: offline caches, datasets
  async setCache(key, value) {
    if(!this.db) await this.initDB();
    const tx = this.db.transaction('cache', 'readwrite');
    return new Promise((resolve) => {
      tx.objectStore('cache').put({ key, value });
      tx.oncomplete = () => resolve();
    });
  }
  
  // Runtime: component state, computed values
  setMemory(key, value) {
    this.memory[key] = value;
  }
  
  getMemory(key) {
    return this.memory[key];
  }
}

Security Best Practices

✅ DO:

// 1. Auth tokens in httpOnly cookies only
// Server sets: Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict
 
// 2. Validate data after retrieval
const user = JSON.parse(localStorage.getItem('user'));
if(user && typeof user.id === 'number') {
  // Safe to use
}
 
// 3. Encrypt sensitive data in IndexedDB
const encrypted = await crypto.subtle.encrypt(key, plaintext);
await db.put('sensitive', encrypted);
 
// 4. Check quota before storing large data
const estimate = await navigator.storage.estimate();
if(estimate.usage / estimate.quota > 0.8) {
  console.warn('Storage 80% full');
}

❌ DON'T:

// Don't store tokens in localStorage (XSS can steal them)
localStorage.setItem('authToken', token); // ❌ BAD
 
// Don't trust localStorage implicitly (user can edit)
if(localStorage.getItem('isAdmin') === 'true') { // ❌ BAD
 
// Don't ignore quota errors
try {
  localStorage.setItem('huge_data', massive_string);
} catch(e) {
  // ❌ Don't silently fail
}
 
// Don't store passwords, API keys, secrets
localStorage.setItem('apiKey', secretKey); // ❌ NEVER

The Bottom Line

  • Auth tokens → Secure httpOnly cookies
  • User preferences → LocalStorage
  • Form wizards → SessionStorage
  • Large datasets → IndexedDB
  • Offline apps → Service Worker + Cache API
  • Runtime state → Memory/variables
  • Everything else → Start with LocalStorage, graduate to IndexedDB if you need queries

Pick the right tool and your data handling becomes boring—which is exactly what you want.

Server-side tracking insights, in your inbox

Case studies and engineering deep-dives — a few emails a year, no noise.

No spam. Unsubscribe any time with one reply.