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.
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:
| Storage | Capacity | Persistence | Sync/Async | Accessible from | Best for |
|---|---|---|---|---|---|
| localStorage | ~5-10MB | Until cleared | Sync | Main thread | Preferences, small state |
| sessionStorage | ~5-10MB | Until tab closes | Sync | Main thread | Temporary form data |
| IndexedDB | Hundreds of MB+ | Until cleared | Async | Main thread + workers | Structured data, offline apps |
| Cache API | Hundreds of MB+ | Until cleared | Async | Main thread + workers | HTTP responses, offline assets |
| Cookies | ~4KB per cookie | Configurable | Sync | Main thread + server | Auth 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
storageevent 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.
Related articles
- Web Cookies vs localStorage vs sessionStorage
Compare the three main browser storage mechanisms by lifecycle, capacity, security, and use case so you pick the right tool for tokens, preferences, and state.
- Web Advanced Browser DevTools Tips and Tricks
Go beyond console.log: Network throttling, Performance flame charts, Memory snapshots, CSS debugging, JavaScript profiling, and hidden DevTools features that save hours.
- Web The Web Animations API: CSS vs JavaScript Animations
A practical guide to the Web Animations API (WAAPI): creating animations in JavaScript, controlling playback, composing effects, performance tips, and when to use CSS vs JS.
- Web Measuring and Fixing Core Web Vitals
Hands-on techniques for measuring LCP, INP, and CLS with real code: the web-vitals library, Performance Observer, Chrome UX Report, and step-by-step fixes for each metric.