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.
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
HttpOnlyflag 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
storageevents 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
storageevent 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 vanishesWhen 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); // ❌ NEVERThe 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.