Browser Storage: Cookies, localStorage, sessionStorage, IndexedDB
The browser provides four mechanisms for persisting data on the client side. They are not interchangeable - each has a specific set of characteristics that makes it appropriate for certain use cases and inappropriate for others. Using the wrong one doesn’t always cause an obvious failure; it often causes a subtle security issue or unexpected behavior that surfaces later.
Cookies
Cookies predate the modern web and carry that legacy in their design. They are sent automatically with every HTTP request to the same domain, which is both their primary feature and their primary risk.
// Setting a cookie
document.cookie = "username=milan; expires=Fri, 01 Jan 2027 00:00:00 UTC; path=/";
// Reading cookies (returns all cookies as one string - parsing required)
const cookies = document.cookie; // "username=milan; theme=dark"
// Deleting a cookie (set expiry to the past)
document.cookie = "username=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";
The raw cookie API is unpleasant. In practice, use a library or the newer Cookie Store API.
The critical attributes:
HttpOnly: prevents JavaScript from reading the cookie. A cookie marked HttpOnly is only sent to the server, never accessible via document.cookie. This is the protection against XSS - if an attacker injects JavaScript, they cannot steal HttpOnly cookies.
Secure: only send the cookie over HTTPS. Never set a session cookie without this in production.
SameSite: controls whether the cookie is sent with cross-site requests. SameSite=Strict means the cookie is never sent on cross-site requests. SameSite=Lax (the modern default) allows the cookie on top-level navigations. SameSite=None (with Secure) allows all cross-site use. This is the protection against CSRF.
Expires/Max-Age: when the cookie expires. Session cookies (no expiry) are deleted when the browser closes. Persistent cookies survive browser restarts.
When to use cookies: authentication tokens and session identifiers. The automatic inclusion in HTTP requests makes cookies the right mechanism for credentials that the server needs to see. Use HttpOnly, Secure, and SameSite=Lax at minimum.
Capacity: 4KB per cookie, around 20-50 cookies per domain.
localStorage
localStorage stores key-value pairs (strings) that persist indefinitely - they survive browser restarts and have no built-in expiry.
// Store
localStorage.setItem('theme', 'dark');
localStorage.setItem('user_prefs', JSON.stringify({ fontSize: 16, lang: 'en' }));
// Read
const theme = localStorage.getItem('theme'); // 'dark'
const prefs = JSON.parse(localStorage.getItem('user_prefs'));
// Delete
localStorage.removeItem('theme');
localStorage.clear(); // removes everything
localStorage is synchronous - reads and writes block the main thread. For small data this is imperceptible. For large data (multiple MB), it causes jank.
localStorage is accessible by any JavaScript on the same origin. This makes it vulnerable to XSS: if an attacker can inject JavaScript, they can read everything in localStorage. Never store authentication tokens in localStorage. This is a common mistake that produces vulnerable applications.
When to use localStorage: user preferences, non-sensitive application state, data that should persist across browser sessions. Theme settings, language preferences, dismissal of onboarding prompts, layout settings.
What to avoid: authentication credentials, sensitive personal data, anything that should have an expiry.
Capacity: 5-10MB depending on the browser.
sessionStorage
sessionStorage has the same API as localStorage but with one difference: it’s scoped to the browser tab and cleared when the tab closes.
// Same API as localStorage
sessionStorage.setItem('draft_comment', 'Work in progress...');
const draft = sessionStorage.getItem('draft_comment');
“Session” here means the browser tab’s lifetime, not the user’s login session. Opening the same URL in a new tab creates an empty, separate sessionStorage. Duplicating a tab creates a copy of the parent tab’s sessionStorage that then evolves independently.
When to use sessionStorage: single-page flow state that doesn’t need to persist. Multi-step form progress where abandoning and reopening should start fresh. Temporary state that would be confusing if it persisted across different browser sessions.
Capacity: same as localStorage, 5-10MB.
IndexedDB
IndexedDB is a full client-side database: structured data, indexes, transactions, asynchronous operations. It’s significantly more complex than the other options but handles use cases they cannot.
// Opening a database
const request = indexedDB.open('my-app-db', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('messages', { keyPath: 'id' });
store.createIndex('by-date', 'timestamp');
};
request.onsuccess = (event) => {
const db = event.target.result;
// Write
const tx = db.transaction('messages', 'readwrite');
tx.objectStore('messages').add({
id: 1,
text: 'Hello',
timestamp: Date.now()
});
// Read with index
const readTx = db.transaction('messages', 'readonly');
const index = readTx.objectStore('messages').index('by-date');
const rangeRequest = index.getAll(IDBKeyRange.lowerBound(Date.now() - 86400000));
rangeRequest.onsuccess = () => console.log(rangeRequest.result);
};
The raw API is verbose. Use a wrapper library: idb (by Jake Archibald) provides a Promise-based interface.
import { openDB } from 'idb';
const db = await openDB('my-app-db', 1, {
upgrade(db) {
const store = db.createObjectStore('messages', { keyPath: 'id' });
store.createIndex('by-date', 'timestamp');
}
});
// Write
await db.add('messages', { id: 1, text: 'Hello', timestamp: Date.now() });
// Read
const recent = await db.getAllFromIndex('messages', 'by-date',
IDBKeyRange.lowerBound(Date.now() - 86400000));
When to use IndexedDB: offline-first applications that store significant amounts of structured data locally. Drafts, queued actions to sync when back online, local search indexes, large media metadata. If you need to query, filter, or sort data on the client, IndexedDB is the right tool.
Capacity: typically limited to a percentage of available disk space (hundreds of MB to GB), with browser prompts for large amounts.
Choosing the Right One
| Need | Use |
|---|---|
| Authentication/session tokens | Cookie (HttpOnly, Secure, SameSite) |
| User preferences that persist across sessions | localStorage |
| Form state for a multi-step flow | sessionStorage |
| Offline-capable app with structured data | IndexedDB |
| Large files or binary data | IndexedDB |
The security-critical rule: authentication tokens belong in HttpOnly cookies. They are the only storage mechanism where JavaScript cannot read the value, which means XSS attacks cannot steal them. The convenience of localStorage (getItem is simpler than parsing a cookie) is not worth the attack surface it creates.
Everything else is a tradeoff between simplicity, persistence scope, capacity, and query capability. For most user-facing state, localStorage covers the common cases. For anything data-intensive or offline-capable, reach for IndexedDB.