Browser Storage: localStorage, sessionStorage, and IndexedDB
Every web application needs to store data somewhere. Whether you’re saving user preferences, caching API responses, or persisting application state, browser storage gives you three main options: localStorage, sessionStorage, and IndexedDB. Each serves a different purpose, and picking the right one avoids both complexity and data loss.
Why browser storage matters
Before diving into the APIs, let’s understand why browser storage is essential:
- Persist user preferences: themes, language settings, or UI state
- Cache data: reduce server requests by storing API responses
- Enable offline functionality: Progressive Web Apps (PWAs) rely on storage
- Improve performance: store computed results locally
Each storage option has different characteristics. Let’s explore them.
localStorage: simple key-value storage
The localStorage API provides a simple interface for storing string data that persists across browser sessions. It’s synchronous and straightforward to use.
Writing and reading data
localStorage stores everything as strings, so objects and arrays must be serialized with JSON.stringify before writing and parsed with JSON.parse when reading. For values that might be absent, provide a fallback to avoid null errors in downstream code:
// Write: store a string and JSON-serialized object
localStorage.setItem('username', 'alice');
localStorage.setItem('theme', 'dark');
const user = { id: 1, name: 'Alice', role: 'admin' };
localStorage.setItem('user', JSON.stringify(user));
// Read: retrieve and parse, with a fallback for missing keys
const username = localStorage.getItem('username');
const userData = JSON.parse(localStorage.getItem('user'));
const theme = localStorage.getItem('theme') || 'light';
Removing data
Both removeItem and clear are synchronous and immediate. Use removeItem when you want to delete a single key while keeping the rest; use clear sparingly since it wipes everything for the origin:
// Remove a single item
localStorage.removeItem('username');
// Clear all localStorage data
localStorage.clear();
Storage limits and caveats
localStorage has some important limitations:
- Capacity: Typically 5-10 MB per origin
- Type restriction: Only stores strings (hence JSON.stringify)
- Synchronous: Blocks the main thread for large operations
- Not secure: Data is visible in browser DevTools
// Check available storage space
const estimate = navigator.storage.estimate();
estimate.then(({ quota, usage }) => {
console.log(`Using ${usage} of ${quota} bytes`);
});
sessionStorage: session-scoped storage
The sessionStorage API uses the same interface as localStorage but clears data when the browser tab or window closes. This makes it ideal for temporary data like form drafts, authentication tokens for the duration of a tab, or wizard state that should not survive a page close.
Basic Usage
// Set and get items (same API as localStorage)
sessionStorage.setItem('tempToken', 'abc123');
const token = sessionStorage.getItem('tempToken');
// Remove when done
sessionStorage.removeItem('tempToken');
Practical example: form progress
The most common use for sessionStorage is protecting form input from accidental refresh. Listen for input events, save the value to sessionStorage, and restore it on page load. When the form submits successfully, clear the draft so stale data does not reappear:
// Save form input as user types
document.querySelector('#comment').addEventListener('input', (e) => {
sessionStorage.setItem('draftComment', e.target.value);
});
// Restore on page load
window.addEventListener('DOMContentLoaded', () => {
const draft = sessionStorage.getItem('draftComment');
if (draft) {
document.querySelector('#comment').value = draft;
}
});
// Clear on successful submission
document.querySelector('#comment-form').addEventListener('submit', () => {
sessionStorage.removeItem('draftComment');
});
The difference between sessionStorage and localStorage is only in lifetime, not in API surface or capacity. Both use the same getItem/setItem/removeItem methods and share the same origin-level storage limits. The distinction matters most when deciding whether data should survive a tab close:
Key difference from localStorage
// localStorage: persists after closing browser
localStorage.setItem('test', 'persistent');
// → Still available after closing and reopening browser
// sessionStorage: cleared when tab closes
sessionStorage.setItem('test', 'temporary');
// → Gone when tab/window closes
IndexedDB: complex data storage
For large amounts of structured data, IndexedDB provides a powerful NoSQL-like database in the browser. It handles objects, blobs, and files natively, supports indexes for fast queries, and operates asynchronously so it never blocks the main thread. Unlike localStorage and sessionStorage, IndexedDB uses transactions to keep data consistent:
Opening a Database
// Open or create a database
const request = indexedDB.open('MyAppDB', 1);
// Handle upgrades (schema changes)
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create an object store
if (!db.objectStoreNames.contains('users')) {
const store = db.createObjectStore('users', { keyPath: 'id' });
// Create indexes for querying
store.createIndex('email', 'email', { unique: true });
store.createIndex('name', 'name', { unique: false });
}
};
request.onsuccess = (event) => {
console.log('Database opened successfully');
};
request.onerror = (event) => {
console.error('Database error:', event.target.error);
};
Wrapped with promises
The raw IndexedDB API is callback-driven and verbose. Every operation requires listening to onsuccess and onerror events on a request object. Wrapping IndexedDB operations in promises makes the code readable and composable with async/await:
// Helper function to wrap IndexedDB operations
function dbOperation(dbName, storeName, mode) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
resolve({ db, transaction, store });
};
request.onerror = () => reject(request.error);
});
}
// Add a record
async function addUser(user) {
const { store } = await dbOperation('MyAppDB', 'users', 'readwrite');
return new Promise((resolve, reject) => {
const request = store.add(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get a record by key
async function getUser(id) {
const { store } = await dbOperation('MyAppDB', 'users', 'readonly');
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Query with index
async function getUserByEmail(email) {
const { store } = await dbOperation('MyAppDB', 'users', 'readonly');
const index = store.index('email');
return new Promise((resolve, reject) => {
const request = index.get(email);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Practical example: offline data sync
IndexedDB is the right choice when your app needs to work without a network. The pattern is straightforward: fetch data from the server, write it into IndexedDB, and read from the local store when the network is unavailable. The put method upserts records, so repeated syncs do not create duplicates:
// Store API response for offline access
async function cacheUsers(users) {
const { store } = await dbOperation('MyAppDB', 'users', 'readwrite');
const transaction = store.transaction;
users.forEach(user => store.put(user));
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
// Get cached users (works offline)
async function getCachedUsers() {
const { store } = await dbOperation('MyAppDB', 'users', 'readonly');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Choosing the right storage
| Feature | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|
| Capacity | ~5-10 MB | ~5-10 MB | 100+ MB |
| Data types | Strings only | Strings only | Any serializable |
| API style | Synchronous | Synchronous | Asynchronous |
| Queries | Key only | Key only | Indexes |
| Persistence | Forever | Session | Forever |
| Best for | User prefs | Temp data | Large/complex data |
Quick decision guide
- User preferences → localStorage
- Form drafts, temp state → sessionStorage
- Large datasets, offline cache → IndexedDB
- Sensitive data → None of these (use server-side storage)
Storage Events
Both localStorage and sessionStorage fire events when data changes in other tabs or windows:
window.addEventListener('storage', (event) => {
console.log('Storage changed:', {
key: event.key,
oldValue: event.oldValue,
newValue: event.newValue,
storageArea: event.storageArea
});
});
// This event only fires in other tabs, not the originating tab
This is useful for synchronizing state across multiple browser tabs.
Choosing the right store
Browser storage works best when you pick the smallest tool that can hold the data safely. localStorage is fine for simple preferences, sessionStorage is good for temporary state, and IndexedDB is the better choice when the data grows large or needs structure. That choice matters because each API has a different cost. A tiny theme setting should not go into a database, and a large offline cache should not be pushed through a synchronous string store.
Thinking about lifetime helps too. Ask how long the data should live, who should be able to see it, and whether another tab should be able to react when it changes. Those answers usually point you to the right API quickly. If the answer is “until the tab closes,” sessionStorage fits. If the answer is “for future visits on this origin,” localStorage may be enough. If the answer includes “many records, large objects, or offline use,” IndexedDB becomes the practical path.
Modeling stored data
The way you shape data before storing it can make later work much easier. Small, explicit objects tend to age better than loose values spread across many keys. A single record with a few clear fields is easier to validate and easier to migrate later. It also reduces the chance that one part of the app writes a key that another part never reads. That kind of mismatch is one of the most common storage bugs in browser apps.
It is also smart to keep the storage layer separate from UI code. The UI should ask for a setting or a record, while the storage module decides how that data is encoded and decoded. That separation makes the app easier to test and makes future changes less risky. If you later switch from localStorage to IndexedDB for one feature, the rest of the app should not need to know the details.
Reliability and limits
Browser storage is convenient, but it still lives in the real world. Users can clear it, browsers can enforce quotas, and private browsing modes can change the rules. Good code treats stored data as something that can disappear and can be rebuilt. That means checking for missing values, handling parse errors, and having a fallback path when a record is absent. A storage layer that assumes data is always present tends to break in the first unusual session.
It also helps to think about performance in the browser. Synchronous storage calls are easy to write, but large values can block the main thread. IndexedDB is more work up front, yet it gives you more room to grow without freezing the page. The best choice is usually the one that matches both the size of the data and the way the app needs to access it over time.
Summary
You now understand three fundamental browser storage mechanisms:
- localStorage: simple, persistent key-value storage for small data
- sessionStorage: temporary key-value storage scoped to a tab/window
- IndexedDB: powerful NoSQL database for large, structured data
Each has its place in modern web applications. Start with localStorage for simple needs, reach for IndexedDB when you need scale, and use sessionStorage for ephemeral data.
In the next tutorial, we’ll explore the Fetch API for making network requests.
Final storage note
If you keep the storage layer small and explicit, it becomes much easier to swap one API for another later. That is especially useful when a feature starts with a simple key-value store and later needs a bigger data model. Clear storage boundaries make that transition much less painful because the rest of the app already knows where the data lives.