Skip to content
Codeloom
Web

Web Storage APIs Compared: localStorage, IndexedDB, Cache API

A practical comparison of browser storage options: localStorage, sessionStorage, IndexedDB, Cache API, and cookies. When to use each, with real code examples and capacity limits.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • The capacity, persistence, and API differences between each storage option
  • How to use IndexedDB for structured data with indexes and queries
  • How the Cache API stores HTTP responses for offline support
  • When cookies are the right choice over web storage
  • How to check storage quotas and handle quota exceeded errors

Prerequisites

  • JavaScript fundamentals
  • Basic understanding of async/await

The Storage Landscape

Browsers offer five main storage mechanisms. Each has different capacity, persistence, API style, and use cases:

StorageCapacityPersistenceSync/AsyncAccessible fromBest for
localStorage~5-10MBUntil clearedSyncMain threadPreferences, small state
sessionStorage~5-10MBUntil tab closesSyncMain threadTemporary form data
IndexedDBHundreds of MB+Until clearedAsyncMain thread + workersStructured data, offline apps
Cache APIHundreds of MB+Until clearedAsyncMain thread + workersHTTP responses, offline assets
Cookies~4KB per cookieConfigurableSyncMain thread + serverAuth tokens, server-readable state

localStorage and sessionStorage

Both share the same API. The difference is lifetime: localStorage persists until explicitly cleared, sessionStorage is scoped to the browser tab and cleared when the tab closes.

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

// Read
const theme = localStorage.getItem('theme'); // 'dark'
const user = JSON.parse(localStorage.getItem('user') ?? 'null');

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

// Clear everything
localStorage.clear();

// Check storage size
function getStorageSize(storage: Storage): string {
  let total = 0;
  for (let i = 0; i < storage.length; i++) {
    const key = storage.key(i)!;
    total += key.length + (storage.getItem(key)?.length ?? 0);
  }
  return `${(total * 2 / 1024).toFixed(1)} KB`; // UTF-16 = 2 bytes per char
}
console.log('localStorage usage:', getStorageSize(localStorage));

A Typed Wrapper

Raw getItem/setItem is error-prone. Wrap it:

// src/lib/storage.ts
export function createStorage<T extends Record<string, unknown>>(
  prefix: string,
  defaults: T,
  storage: Storage = localStorage,
) {
  function get<K extends keyof T>(key: K): T[K] {
    const raw = storage.getItem(`${prefix}:${String(key)}`);
    if (raw === null) return defaults[key];
    try {
      return JSON.parse(raw) as T[K];
    } catch {
      return defaults[key];
    }
  }

  function set<K extends keyof T>(key: K, value: T[K]): void {
    storage.setItem(`${prefix}:${String(key)}`, JSON.stringify(value));
  }

  function remove<K extends keyof T>(key: K): void {
    storage.removeItem(`${prefix}:${String(key)}`);
  }

  return { get, set, remove };
}

// Usage
const settings = createStorage('app', {
  theme: 'system' as 'light' | 'dark' | 'system',
  fontSize: 16,
  notifications: true,
});

settings.set('theme', 'dark');
const theme = settings.get('theme'); // typed as 'light' | 'dark' | 'system'

When Not to Use localStorage

  • Sensitive data: localStorage is accessible to any script on the page. XSS = data stolen.
  • Large data: The 5-10MB limit is per origin. Exceeding it throws a QuotaExceededError.
  • Structured data: No indexes, no queries. Everything is a string.
  • Cross-tab sync: localStorage fires a storage event on other tabs, but it is a clunky sync mechanism.

IndexedDB

IndexedDB is a transactional database in the browser. It stores structured data with indexes, handles large datasets, and works in web workers.

The Modern Way: idb Library

The raw IndexedDB API is callback-based and verbose. The idb library wraps it with promises:

npm install idb
// src/lib/db.ts
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';

interface AppDB extends DBSchema {
  tasks: {
    key: string;
    value: {
      id: string;
      title: string;
      completed: boolean;
      createdAt: number;
      category: string;
    };
    indexes: {
      'by-category': string;
      'by-date': number;
    };
  };
  drafts: {
    key: string;
    value: {
      id: string;
      content: string;
      updatedAt: number;
    };
  };
}

let dbPromise: Promise<IDBPDatabase<AppDB>>;

export function getDB() {
  if (!dbPromise) {
    dbPromise = openDB<AppDB>('my-app', 1, {
      upgrade(db) {
        // Create tasks store with indexes
        const taskStore = db.createObjectStore('tasks', { keyPath: 'id' });
        taskStore.createIndex('by-category', 'category');
        taskStore.createIndex('by-date', 'createdAt');

        // Create drafts store
        db.createObjectStore('drafts', { keyPath: 'id' });
      },
    });
  }
  return dbPromise;
}

CRUD Operations

// src/lib/task-store.ts
import { getDB } from './db';

export async function addTask(task: AppDB['tasks']['value']) {
  const db = await getDB();
  await db.put('tasks', task);
}

export async function getTask(id: string) {
  const db = await getDB();
  return db.get('tasks', id);
}

export async function getAllTasks() {
  const db = await getDB();
  return db.getAll('tasks');
}

export async function getTasksByCategory(category: string) {
  const db = await getDB();
  return db.getAllFromIndex('tasks', 'by-category', category);
}

export async function getRecentTasks(limit: number) {
  const db = await getDB();
  const tx = db.transaction('tasks', 'readonly');
  const index = tx.store.index('by-date');
  const tasks: AppDB['tasks']['value'][] = [];

  // Iterate in reverse order (newest first)
  let cursor = await index.openCursor(null, 'prev');
  while (cursor && tasks.length < limit) {
    tasks.push(cursor.value);
    cursor = await cursor.continue();
  }

  return tasks;
}

export async function deleteTask(id: string) {
  const db = await getDB();
  await db.delete('tasks', id);
}

export async function deleteCompletedTasks() {
  const db = await getDB();
  const tx = db.transaction('tasks', 'readwrite');
  const all = await tx.store.getAll();

  await Promise.all(
    all.filter((t) => t.completed).map((t) => tx.store.delete(t.id)),
  );

  await tx.done;
}

Transactions

IndexedDB operations happen inside transactions. The idb library handles single-operation transactions automatically, but for multi-step operations:

async function moveTaskToCategory(taskId: string, newCategory: string) {
  const db = await getDB();
  const tx = db.transaction('tasks', 'readwrite');

  const task = await tx.store.get(taskId);
  if (task) {
    task.category = newCategory;
    await tx.store.put(task);
  }

  await tx.done; // Commit the transaction
}

Cache API

The Cache API stores HTTP request/response pairs. It is designed for service workers but works on the main thread too.

// Cache static assets
async function cacheAssets() {
  const cache = await caches.open('static-v1');

  await cache.addAll([
    '/',
    '/styles/main.css',
    '/scripts/app.js',
    '/images/logo.svg',
  ]);
}

// Cache a single response
async function cacheResponse(request: Request, response: Response) {
  const cache = await caches.open('api-v1');
  await cache.put(request, response.clone());
}

// Read from cache
async function getCached(url: string): Promise<Response | undefined> {
  const cache = await caches.open('api-v1');
  return cache.match(url);
}

// Delete a cached response
async function uncache(url: string) {
  const cache = await caches.open('api-v1');
  await cache.delete(url);
}

// List all caches
async function listCaches() {
  const keys = await caches.keys();
  console.log('Caches:', keys);
}

// Delete an entire cache
async function clearOldCaches(currentVersion: string) {
  const keys = await caches.keys();
  await Promise.all(
    keys.filter((k) => k !== currentVersion).map((k) => caches.delete(k)),
  );
}

Cache with Network Fallback

async function fetchWithCache(url: string, cacheName = 'api-v1'): Promise<Response> {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(url);

  // Return cached if available and fresh
  if (cached) {
    const cacheDate = cached.headers.get('x-cache-date');
    const age = cacheDate ? Date.now() - new Date(cacheDate).getTime() : Infinity;

    if (age < 5 * 60 * 1000) {
      // Less than 5 minutes old
      return cached;
    }
  }

  try {
    const response = await fetch(url);

    if (response.ok) {
      // Add a custom header with the cache timestamp
      const headers = new Headers(response.headers);
      headers.set('x-cache-date', new Date().toISOString());

      const cachedResponse = new Response(await response.clone().blob(), {
        status: response.status,
        headers,
      });

      await cache.put(url, cachedResponse);
    }

    return response;
  } catch {
    if (cached) return cached;
    throw new Error('Network error and no cache available');
  }
}

Storage Quotas

Browsers limit how much storage each origin can use. Check the quota:

async function checkStorageQuota() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usedMB = ((estimate.usage ?? 0) / (1024 * 1024)).toFixed(1);
    const quotaMB = ((estimate.quota ?? 0) / (1024 * 1024)).toFixed(0);
    const percentUsed = (((estimate.usage ?? 0) / (estimate.quota ?? 1)) * 100).toFixed(1);

    console.log(`Storage: ${usedMB}MB / ${quotaMB}MB (${percentUsed}%)`);
  }
}

// Request persistent storage (prevents browser from evicting data)
async function requestPersistence() {
  if ('storage' in navigator && 'persist' in navigator.storage) {
    const granted = await navigator.storage.persist();
    console.log('Persistent storage:', granted ? 'granted' : 'denied');
  }
}

Handling QuotaExceededError

async function safeStore(key: string, value: string) {
  try {
    localStorage.setItem(key, value);
  } catch (error) {
    if (error instanceof DOMException && error.name === 'QuotaExceededError') {
      // Clear least-recently-used items
      console.warn('Storage quota exceeded, clearing old data');
      clearOldEntries();
      // Retry
      try {
        localStorage.setItem(key, value);
      } catch {
        console.error('Storage full even after cleanup');
      }
    }
  }
}

Decision Guide

  • User preferences (theme, language, font size): localStorage
  • Form draft state: sessionStorage (disappears when tab closes)
  • Auth tokens: httpOnly cookies (not accessible to JavaScript)
  • Offline data (tasks, messages, articles): IndexedDB
  • Offline assets (HTML, CSS, JS, images): Cache API
  • Large binary files (images, PDFs, videos): Cache API or IndexedDB with Blob values
  • Data shared with the server on every request: Cookies

Summary

The browser offers five storage mechanisms, each optimized for a different use case. localStorage is simple but limited and synchronous. sessionStorage adds tab scoping. IndexedDB is a full database with indexes and transactions. Cache API stores HTTP responses for offline support. Cookies are the only storage the server sees on every request. Pick the right tool for the data shape, size, and lifetime you need, and always handle quota errors gracefully.