Browser Storage: Cookies vs LocalStorage vs IndexedDB

Browser storage isn't one-size-fits-all. Each mechanism—cookies, LocalStorage, and IndexedDB—solves different problems, and choosing the wrong one creates performance bottlenecks, security...

Key Insights

  • Cookies are for small data (4KB) that needs server access on every request; LocalStorage handles 5-10MB of simple key-value data; IndexedDB manages large structured datasets with querying capabilities
  • Security matters: cookies can be HttpOnly to prevent XSS, but LocalStorage and IndexedDB are always JavaScript-accessible, requiring encryption for sensitive data
  • Performance varies drastically: LocalStorage is synchronous and blocks the main thread, while IndexedDB’s asynchronous API handles large datasets without freezing your UI

Introduction & Use Cases

Browser storage isn’t one-size-fits-all. Each mechanism—cookies, LocalStorage, and IndexedDB—solves different problems, and choosing the wrong one creates performance bottlenecks, security vulnerabilities, or architectural headaches.

Here’s the fundamental breakdown:

Feature Cookies LocalStorage IndexedDB
Capacity 4KB 5-10MB 50MB+ (varies by browser)
Persistence Configurable expiration Until explicitly cleared Until explicitly cleared
Server Access Sent with every HTTP request JavaScript only JavaScript only
API Type Synchronous Synchronous Asynchronous
Best For Authentication tokens, session IDs User preferences, cached API responses Offline-first apps, large datasets

Here’s a decision helper:

function recommendStorage(requirements) {
  const { dataSize, needsServerAccess, structured, frequency } = requirements;
  
  // Data needs to go to server automatically
  if (needsServerAccess) {
    if (dataSize < 4000) return 'cookie';
    return 'error: cookies too small, use Authorization header instead';
  }
  
  // Large or structured data
  if (dataSize > 5000000 || structured) {
    return 'IndexedDB';
  }
  
  // Simple key-value storage
  if (dataSize < 5000000) {
    return frequency === 'high' ? 'localStorage' : 'sessionStorage';
  }
  
  return 'IndexedDB';
}

// Usage examples
console.log(recommendStorage({ 
  dataSize: 2000, 
  needsServerAccess: true 
})); // 'cookie'

console.log(recommendStorage({ 
  dataSize: 100000, 
  structured: true 
})); // 'IndexedDB'

HTTP Cookies

Cookies exist primarily for server communication. Every cookie gets sent with every HTTP request to its domain, making them perfect for authentication but terrible for large data.

The 4KB limit includes the entire cookie string: name, value, and attributes. Exceed it, and browsers silently truncate or reject your cookie.

// Set a cookie
function setCookie(name, value, days) {
  const date = new Date();
  date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
  const expires = `expires=${date.toUTCString()}`;
  document.cookie = `${name}=${value};${expires};path=/`;
}

// Read a cookie
function getCookie(name) {
  const nameEQ = name + "=";
  const cookies = document.cookie.split(';');
  for (let cookie of cookies) {
    cookie = cookie.trim();
    if (cookie.indexOf(nameEQ) === 0) {
      return cookie.substring(nameEQ.length);
    }
  }
  return null;
}

// Delete a cookie
function deleteCookie(name) {
  document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
}

// Usage
setCookie('user_id', '12345', 7);
console.log(getCookie('user_id')); // '12345'
deleteCookie('user_id');

Security Attributes Matter

The SameSite attribute prevents CSRF attacks. Secure ensures HTTPS-only transmission. HttpOnly blocks JavaScript access entirely (server-side only).

// Session cookie (expires when browser closes)
document.cookie = "session_id=abc123;path=/;SameSite=Strict;Secure";

// Long-lived cookie with CSRF protection
document.cookie = "preferences=dark_mode;max-age=31536000;SameSite=Lax;Secure";

// Authentication token (JavaScript can't access this)
// Must be set server-side with HttpOnly flag
// Set-Cookie: auth_token=xyz789;HttpOnly;Secure;SameSite=Strict

Critical point: If JavaScript can read your authentication tokens from cookies, you’re vulnerable to XSS attacks. Use HttpOnly cookies for anything security-sensitive.

LocalStorage & SessionStorage

The Web Storage API provides a simple key-value store with significantly more space than cookies. LocalStorage persists forever; SessionStorage clears when the tab closes.

Both are synchronous, which means large read/write operations block your main thread. Keep operations small and infrequent.

Basic Operations

// LocalStorage CRUD
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme');
localStorage.removeItem('theme');
localStorage.clear(); // Nuclear option

// Storing objects requires serialization
const user = { name: 'Alice', role: 'admin' };
localStorage.setItem('user', JSON.stringify(user));

const storedUser = JSON.parse(localStorage.getItem('user'));
console.log(storedUser.name); // 'Alice'

// Helper functions for cleaner code
const storage = {
  set(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  },
  get(key) {
    const item = localStorage.getItem(key);
    try {
      return JSON.parse(item);
    } catch {
      return item;
    }
  },
  remove(key) {
    localStorage.removeItem(key);
  }
};

storage.set('settings', { notifications: true, theme: 'dark' });
console.log(storage.get('settings').theme); // 'dark'

Cross-Tab Communication

LocalStorage fires storage events in other tabs when data changes. SessionStorage doesn’t.

// Tab 1: Listen for changes
window.addEventListener('storage', (e) => {
  if (e.key === 'cart') {
    console.log('Cart updated in another tab');
    console.log('Old value:', e.oldValue);
    console.log('New value:', e.newValue);
    updateCartUI(JSON.parse(e.newValue));
  }
});

// Tab 2: Modify data
const cart = storage.get('cart') || [];
cart.push({ id: 123, name: 'Widget' });
storage.set('cart', cart); // Tab 1's listener fires

SessionStorage is identical API-wise but scoped to a single tab:

// Different tabs have different sessionStorage
sessionStorage.setItem('temp_form', JSON.stringify(formData));

// Persists through page refreshes in the same tab
// Disappears when tab closes

IndexedDB

IndexedDB is a transactional database system built into browsers. It handles structured data, supports indexes for fast queries, and operates asynchronously to avoid blocking the UI.

The API is verbose and callback-heavy, but it’s powerful for offline-first applications and large datasets.

Creating a Database

function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('AppDatabase', 1);
    
    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
    
    // Runs only when version changes
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      
      // Create object store (like a table)
      if (!db.objectStoreNames.contains('users')) {
        const objectStore = db.createObjectStore('users', { 
          keyPath: 'id', 
          autoIncrement: true 
        });
        
        // Create indexes for fast lookups
        objectStore.createIndex('email', 'email', { unique: true });
        objectStore.createIndex('name', 'name', { unique: false });
      }
    };
  });
}

CRUD Operations with Async/Await

class IndexedDBHelper {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
  }
  
  async getDB() {
    if (this.db) return this.db;
    this.db = await openDatabase();
    return this.db;
  }
  
  async add(data) {
    const db = await this.getDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.add(data);
      
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  async get(id) {
    const db = await this.getDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      const request = store.get(id);
      
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  async getByIndex(indexName, value) {
    const db = await this.getDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      const index = store.index(indexName);
      const request = index.get(value);
      
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  async getAll() {
    const db = await this.getDB();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      const request = store.getAll();
      
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

// Usage
const userDB = new IndexedDBHelper('AppDatabase', 'users');

await userDB.add({ name: 'Alice', email: 'alice@example.com' });
await userDB.add({ name: 'Bob', email: 'bob@example.com' });

const user = await userDB.getByIndex('email', 'alice@example.com');
console.log(user); // { id: 1, name: 'Alice', email: 'alice@example.com' }

const allUsers = await userDB.getAll();
console.log(allUsers.length); // 2

Security Considerations

All browser storage is vulnerable to XSS attacks. If an attacker can execute JavaScript in your app, they can access LocalStorage and IndexedDB. Cookies with HttpOnly are safe from JavaScript but not from network interception without Secure.

Encrypting Sensitive Data

// Simple encryption (use a proper library like crypto-js in production)
async function encrypt(text, password) {
  const encoder = new TextEncoder();
  const data = encoder.encode(text);
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveBits', 'deriveKey']
  );
  
  const derivedKey = await crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt: encoder.encode('salt'), iterations: 100000, hash: 'SHA-256' },
    key,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt']
  );
  
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    derivedKey,
    data
  );
  
  return { encrypted: btoa(String.fromCharCode(...new Uint8Array(encrypted))), iv: btoa(String.fromCharCode(...iv)) };
}

// Store encrypted data
const sensitiveData = { ssn: '123-45-6789', creditCard: '4111111111111111' };
const { encrypted, iv } = await encrypt(JSON.stringify(sensitiveData), 'user-password');
localStorage.setItem('sensitive', JSON.stringify({ encrypted, iv }));

Better approach: Don’t store sensitive data client-side at all. Keep it server-side and use short-lived tokens.

Performance & Best Practices

LocalStorage blocks the main thread. Reading 1MB of data can freeze your UI for 50-100ms. IndexedDB doesn’t have this problem.

// Bad: Blocks the UI
console.time('localStorage');
for (let i = 0; i < 1000; i++) {
  localStorage.setItem(`key${i}`, JSON.stringify({ data: 'x'.repeat(1000) }));
}
console.timeEnd('localStorage'); // ~200ms, UI frozen

// Good: Doesn't block the UI
console.time('IndexedDB');
const db = new IndexedDBHelper('PerfTest', 'items');
for (let i = 0; i < 1000; i++) {
  await db.add({ data: 'x'.repeat(1000) });
}
console.timeEnd('IndexedDB'); // ~300ms, UI responsive

Best Practices

  1. Use cookies for authentication tokens with HttpOnly, Secure, and SameSite=Strict
  2. Use LocalStorage for small, non-sensitive data like UI preferences
  3. Use IndexedDB for large datasets or anything requiring queries
  4. Never store passwords or credit cards in browser storage
  5. Implement quota management - browsers can clear storage under pressure
  6. Compress data before storing if size matters
// Check storage quota
if (navigator.storage && navigator.storage.estimate) {
  const estimate = await navigator.storage.estimate();
  console.log(`Using ${estimate.usage} of ${estimate.quota} bytes`);
  console.log(`${((estimate.usage / estimate.quota) * 100).toFixed(2)}% used`);
}

Quick Reference & Decision Matrix

Choose Cookies when:

  • Data needs to reach the server on every request
  • Data size < 4KB
  • You need automatic expiration
  • Authentication tokens (with HttpOnly)

Choose LocalStorage when:

  • Simple key-value data < 5MB
  • Data persists across sessions
  • Synchronous access is acceptable
  • Cross-tab communication needed

Choose IndexedDB when:

  • Data size > 5MB
  • Complex queries required
  • Structured data with relationships
  • Offline-first functionality
  • Asynchronous operations preferred

The right choice depends on your specific requirements. Start with the simplest solution that works—usually LocalStorage for client-side data—and migrate to IndexedDB only when you need its capabilities. Cookies should be reserved for server communication, not general-purpose storage.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.