Choosing Between localStorage, sessionStorage, and IndexedDB for HTML5 Data Persistence
A decision guide comparing localStorage, sessionStorage, and IndexedDB for HTML5 apps, with a concrete IndexedDB caching example and validation steps.
12 Mar 2026, 17:52 UTC

When building an HTML5 application, picking the wrong client‑side storage API can cause UI jank, unexpected data loss, or hit quota limits that break offline features. The decision hinges on data size, lifetime, query needs, and tolerance for blocking the main thread.
Decision constraints
- Maximum payload size you expect to store.
- How long the data must survive (page reload, tab close, browser restart).
- Whether you need to search or filter the stored items.
- Acceptable impact on the main thread (e.g., during animations or scroll).
- Browser support quirks, especially Safari private‑mode behavior.
Comparison of the three storage mechanisms
| Feature | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|
| Data model | String key‑value | String key‑value | Object store with indexes |
| API nature | Synchronous | Synchronous | Asynchronous (Promise‑based via wrappers) |
| Typical capacity | ≈5‑10 MB per origin | ≈5‑10 MB per origin | Up to several hundred MB; effectively limited by available disk and quota API |
| Persistence | Until cleared by user or site | Lifetime of the top‑level browsing context (tab) | Until cleared; can be made persistent with navigator.storage.persist() |
| Query capabilities | None (manual iteration) | None (manual iteration) | Indexes, cursors, range queries, transactional reads |
| Main‑thread impact | Blocks on each read/write | Blocks on each read/write | Non‑blocking when using asynchronous API |
| Safari private‑mode quirk | Works | Works | May throw QuotaExceededError on open; needs try/catch fallback |
Trade‑off summary
- localStorage / sessionStorage are simplest for tiny preference flags or UI drafts, but their synchronous calls can cause noticeable frame drops when payloads exceed ~1 MB or when writes happen frequently (e.g., on every keystroke).
- sessionStorage adds tab isolation, making it safe for wizard steps or form drafts that must not leak across tabs.
- IndexedDB scales to large datasets and supports structured queries, but its native API is verbose and error‑prone; using a lightweight wrapper such as
idborDexie.jsis strongly recommended. - All three share the same‑origin policy; any script on the origin can read the data, so never store raw authentication tokens without additional encryption.
When to prefer localStorage or sessionStorage
- Storing a user‑selected theme flag (
dark-mode: true) – a single boolean, negligible size, needs to survive browser restarts →localStorage. - Saving the current step of a multi‑step form that should disappear if the user closes the tab →
sessionStorage. - Keeping a short‑lived token for a single‑page‑app that does not need to survive a refresh →
sessionStorage(still remember to encrypt if sensitive). - Persisting UI preferences like sidebar collapsed state or last‑viewed filter → either API, but prefer
sessionStorageif the preference should not survive a tab close.
When to move to IndexedDB
- Offline‑first caching of resources larger than a few hundred kilobytes (e.g., product catalogs, map tiles).
- Storing user‑generated content that may grow beyond the 5‑10 MB limit (notes, drawings, offline logs).
- Needing to query by property (e.g., "find all incomplete tasks due today") without loading the whole set into memory.
- Working with binary blobs (images, files) where base64‑encoding would inflate size and exceed localStorage limits.
Concrete implementation: caching a product catalog with IndexedDB
Assume an e‑commerce site that needs to show a list of products while offline. The catalog contains ~200 KB of JSON, well within IndexedDB capacity but too large for comfortable synchronous storage.
Setup (using the idb wrapper)
// 1. Import the wrapper (ES module example)
// import { openDB } from 'https://unpkg.com/idb?module';
// 2. Open or create the database
let dbPromise;
function getDb() {
if (!dbPromise) {
dbPromise = openDB('shop-db', 1, {
upgrade(db) {
// Create an object store; use product.id as key
const store = db.createObjectStore('products', { keyPath: 'id' });
// Add an index on category for quick filtering
store.createIndex('by-category', 'category', { unique: false });
},
});
}
return dbPromise;
}
// 3. Store the catalog (run once after fetch)
async function cacheCatalog(catalogArray) {
const db = await getDb();
const tx = db.transaction('products', 'readwrite');
const store = tx.objectStore('products');
// Clear old data then add new entries
await store.clear();
for (const product of catalogArray) {
await store.add(product);
}
await tx.done;
}
// 4. Retrieve all products (non‑blocking)
async function getAllProducts() {
const db = await getDb();
const tx = db.transaction('products', 'readonly');
return await tx.objectStore('products').getAll();
}
// 5. Example usage
(async () => {
try {
const response = await fetch('/api/products');
const catalog = await response.json();
await cacheCatalog(catalog);
console.log('Catalog cached');
} catch (e) {
console.error('Failed to cache catalog:', e);
}
})();
Validation steps
- Open Chrome DevTools → Application → Storage → IndexedDB → shop-db → products. Verify that the number of rows matches the catalog length.
- Run
navigator.storage.estimate()in the console to see current usage versus quota. - To test quota‑exceeded handling, open DevTools → Application → Storage → “Simulate custom storage quota” and set a low limit (e.g., 5 MB). Reload the page and observe that the
cacheCatalogpromise rejects; you can catch the error and show a user‑friendly message. - In Safari, open a private window and repeat the fetch/cache flow; wrap the
openDBcall in a try/catch and fall back to an in‑memory Map if aQuotaExceededErroris caught. - Check main‑thread impact by enabling the “Paint flashing” option in DevTools and scrolling while the catalog is being written; you should see no long frames because the work happens asynchronously.
- Open two tabs, store a value in
localStoragein tab A, then read it in tab B to confirm sharing; repeat withsessionStorageto confirm isolation.
Alternative wrapper comparison
idb– tiny (~2 KB gzipped), provides a Promise‑based API that mirrors the native IndexedDB concepts; ideal when you want minimal overhead and fine‑grained control.Dexie.js– richer feature set (schema versioning, hookable events, live query utilities) at a larger size (~10 KB gzipped); useful for complex applications needing built‑in debugging helpers.idb-keyval– ultra‑simple key‑value wrapper over IndexedDB; good if you only need get/set/delete and want to avoid thinking about object stores.
Limitations and practical checks
- Blocking calls: Avoid writing >1 MB or performing more than a few writes per frame in animation‑critical code. Profile with the Performance panel.
- Quota eviction: Browsers may purge IndexedDB under storage pressure; use
navigator.storage.persist()after a user gesture to request persistent storage for critical data. - String conversion overhead: localStorage/sessionStorage store only UTF‑16 strings; JSON serialization/deserialization adds CPU time and can unexpectedly increase size (e.g., binary data base64‑encoded grows ~33%).
- Safari private‑mode: Always wrap IndexedDB initialization in
try { openDB(...) } catch (e) { if (e.name === 'QuotaExceededError') {/* fallback */ } }. - Security: Treat any stored data as potentially readable by XSS; apply Content Security Policy and avoid storing raw secrets.
- Cross‑tab synchronization: localStorage fires a
storageevent in other tabs when changed; sessionStorage does not. Use this event to keep UI state in sync when needed.
By matching the data’s size, lifetime, and query needs to the characteristics outlined above, you can pick the storage mechanism that keeps your application responsive and reliable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.