Client-Side Storage for Web Apps: What to Use and When

Choosing between cookies, localStorage, IndexedDB, and the Cache API affects security, performance, and offline UX. This practical guide explains each option, common pitfalls, and proven patterns—plus sample code and prompts you can use to build the right approach in your app.

Pick the wrong client-side storage and you inherit subtle bugs, security footguns, and a sluggish UI. Pick the right one and your app feels instant, resilient, and safe. The challenge is that browsers give you multiple ways to store data—cookies, localStorage, IndexedDB, and the Cache API—each with different trade-offs that matter when you’re handling tokens, user preferences, offline data, or large media files.

The short answer: use this for that

If you only take one section with you, make it this one.

  • Authentication session: HttpOnly, Secure cookies with SameSite. Don’t store tokens in localStorage.
  • Small UI preferences (theme, sort order): localStorage or a cookie (non-sensitive only).
  • Large structured data for offline (records, queues, drafts): IndexedDB.
  • Static assets and GET-able API responses for speed/offline: Cache API (ideally via a Service Worker).
  • Anything sensitive (PII, secrets): Prefer server; if you must store client-side, encrypt before storage and plan for key management and secure wipe.

What each storage option actually does

Cookies

Cookies are small name/value pairs that the browser automatically includes with HTTP requests to their origin (depending on scope). That auto-send behavior makes cookies ideal for session identifiers controlled by the server and guarded by HttpOnly and Secure attributes. The server sets and rotates the cookie; front-end JavaScript can’t read an HttpOnly cookie, which is excellent for protecting tokens from XSS. With SameSite, you can dramatically reduce CSRF risk. Cookies are size-limited and not meant for application data.

Good for: sessions. Bad for: large payloads, app state, secrets readable by JS (avoid non-HttpOnly for sensitive data).

localStorage and sessionStorage

These provide simple key/value string storage scoped to an origin. They’re synchronous—reads/writes block the main thread—so they’re great for a few small values but not for heavy use. localStorage persists across tabs and browsing sessions; sessionStorage is per-tab and cleared when the tab closes.

Good for: a handful of non-sensitive preferences. Bad for: tokens (vulnerable to XSS), large datasets (sync overhead), high-frequency writes.

IndexedDB

IndexedDB is an asynchronous, transactional, object-store database built into the browser. It can store structured objects and binary blobs, supports indexes, and works in Web Workers and Service Workers. It’s the right tool for offline caches, background sync queues, and large local datasets. You’ll define object stores and indexes, perform reads/writes in transactions, and handle schema upgrades with a versioned “upgrade” callback.

Good for: large structured data, offline-first features. Bad for: quick-and-dirty values (use localStorage), sensitive secrets you can’t encrypt and manage carefully.

Cache Storage (the Cache API)

The Cache API stores HTTP Request/Response pairs. It’s typically used in a Service Worker to intercept fetches, serve from cache, and update in the background. It’s perfect for static assets (JS/CSS/fonts/images), and can also cache GET API responses. You don’t query it like a database; you match by request. Invalidation is manual, so version your cache names and be deliberate about cache busting.

Good for: speeding up navigation, offline assets, “stale-while-revalidate” for GETs. Bad for: app state, arbitrary objects (it holds responses, not records).

Security first: tokens, PII, and the two big browser threats

Client-side storage decisions are largely security decisions. Two browser attack classes dominate:

  • XSS (Cross-Site Scripting): If an attacker runs JavaScript in your origin, anything readable by JS (localStorage, non-HttpOnly cookies, IndexedDB contents) is at risk.
  • CSRF (Cross-Site Request Forgery): If your app accepts authenticated requests based on cookies, a malicious site can try to trick the browser into sending a victim’s cookies to your origin.

Practical guidance:

  • Use HttpOnly, Secure, SameSite cookies for session identifiers. That removes them from JS visibility (protects against XSS exfiltration) and cuts CSRF risk with SameSite=Lax/Strict.
  • Do not store access tokens in localStorage. It’s a common anti-pattern because XSS can read them. Prefer HttpOnly cookies plus server-managed sessions or rotating tokens managed by the backend.
  • Sanitize and encode all dynamic content to minimize XSS in the first place. Content Security Policy (CSP) with strict script-src helps.
  • Be cautious with PII in IndexedDB/localStorage. If you must store it, use encryption and key management on the client (e.g., derive a key from a user secret that’s not persisted), and provide a reliable wipe path on logout.

Quotas, durability, and eviction

Each browser enforces quotas for client-side storage and may evict data under disk pressure. The details vary by engine and platform. General rules of thumb:

  • Treat localStorage as a small space for non-critical values. Don’t rely on exact size numbers.
  • Use IndexedDB for data you can recompute or re-fetch. Implement retry/backoff and server reconciliation.
  • For PWAs, consider requesting persistent storage to reduce eviction risk where supported.
if (navigator.storage && navigator.storage.persist) { const isPersistent = await navigator.storage.persist(); console.log('Persistent storage granted?', isPersistent); }

Always handle the case where storage is full, fails, or is wiped. Graceful degradation beats brittle assumptions.

Proven patterns and the traps to avoid

Pattern: Cookie-based sessions with HttpOnly + SameSite

Let the server own session lifetime and rotation. The server sets a cookie with appropriate attributes and renews it on activity. Example headers (server-side):

Set-Cookie: app_session=abcdef1234; Path=/; HttpOnly; Secure; SameSite=Lax; Expires=Wed, 01 Jan 2027 00:00:00 GMT

Front-end JS doesn’t read the cookie; it just makes fetch requests with credentials: 'include' when needed:

fetch('/api/projects', { credentials: 'include' }) .then(r => r.json()) .then(console.log) .catch(console.error);

Anti-pattern: Tokens in localStorage

Storing JWTs in localStorage seems convenient until an XSS vulnerability leaks them. Avoid this by using cookies as described above, combined with CSRF mitigations and robust server checks.

Pattern: localStorage for lightweight preferences

Use it for theme, column widths, or last-viewed filters. Keep values tiny, validate on read, and guard against blocking the main thread with heavy loops.

// Save localStorage.setItem('ui.theme', 'dark'); // Read with a default and validation const theme = ['dark','light'].includes(localStorage.getItem('ui.theme')) ? localStorage.getItem('ui.theme') : 'light

Pattern: IndexedDB for offline/large data

Cache records, drafts, or attach a background sync queue. Index by keys you’ll actually query. On startup, reconcile local changes with the server: upload pending items, merge resolved state, and expire old entries.

// Minimal IndexedDB setup const openReq = indexedDB.open('app-db', 1); openReq.onupgradeneeded = () => { const db = openReq.result; const store = db.createObjectStore('tasks', { keyPath: 'id' }); store.createIndex('byProject', 'projectId', { unique: false }); }; openReq.onsuccess = () => { const db = openReq.result; const tx = db.transaction('tasks', 'readwrite'); tx.objectStore('tasks').put({ id: 't1', projectId: 'p1', title: 'Draft task' }); tx.oncomplete = () => db.close(); };

Pattern: Cache API for assets and GET responses (with a Service Worker)

Use a Service Worker to precache essential assets and apply a network strategy (e.g., cache-first for static, stale-while-revalidate for GET API calls that can be slightly stale).

// sw.js self.addEventListener('install', event => { event.waitUntil( caches.open('static-v1').then(cache => cache.addAll([ '/', '/app.css', '/app.js', '/logo.svg' ])) ); }); self.addEventListener('fetch', event => { const url = new URL(event.request.url); if (url.origin === location.origin && url.pathname.startsWith('/api/')) { event.respondWith(staleWhileRevalidate(event.request, 'api-cache')); return; } event.respondWith(cacheFirst(event.request, 'static-v1')); }); async function cacheFirst(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); return cached || fetch(request); } async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); const networkPromise = fetch(request).then(response => { cache.put(request, response.clone()); return response; }); return cached || networkPromise; }

Trap: Assuming client-side encryption solves everything

Encrypting before storage can help, but key management is the hard part. If the key is accessible to JS, XSS may still access decrypted data. Don’t store encryption keys alongside the ciphertext. Consider deriving keys from secrets not persisted locally, and expire/wipe quickly on logout.

Designing a sensible storage strategy for your app

Before you write code, classify data by sensitivity, size, and usage frequency. Then choose storage deliberately.

  1. Map the data: tokens, profile info, preferences, cached lists, large media, drafts.
  2. Score sensitivity: what would be harmful if stolen? Tokens and PII are high risk.
  3. Define lifecycle: how long should it live? When do we rotate or wipe?
  4. Pick storage: cookie for session, localStorage for small prefs, IndexedDB for large/offline, Cache API for assets/GETs.
  5. Plan failure modes: what if storage is full, evicted, or blocked?
  6. Add observability: log cache hits/misses and offline/online transitions (without logging secrets).

Example architecture: A project tracker with offline mode

Imagine a project tracker where users view projects and tasks, work offline, and see a fast UI:

  • Session: server-set HttpOnly cookie (session id), SameSite=Lax, Secure.
  • UI prefs: theme and sort in localStorage.
  • Data: projects, tasks, and comments cached in IndexedDB with stores: projects, tasks, comments, each with updatedAt index.
  • Network: Service Worker caching static assets and GET /api/projects, /api/tasks with stale-while-revalidate.
  • Sync: when offline, new tasks land in an IndexedDB outbox. When back online, a background sync posts them in order and marks them synced.

Basic outbox flow:

// add to outbox when offline await tx.objectStore('outbox').add({ id: crypto.randomUUID(), url: '/api/tasks', method: 'POST', body: { projectId: 'p1', title: 'Write spec' }, createdAt: Date.now() }); // service worker background sync handler (simplified) self.addEventListener('sync', event => { if (event.tag === 'sync-outbox') { event.waitUntil(flushOutbox()); } }); async function flushOutbox() { // open idb, read pending, POST sequentially, mark as synced }

Conflict handling can be as simple as last-write-wins for low-risk data, or more advanced (server-side merge policies, operational transforms, CRDTs) if users concurrently edit the same fields. Even with a simple policy, you should surface “This item changed on the server; review the diff” to avoid silent data loss.

Implementing the basics, step by step

1) Sessions with cookies

Server emits the cookie; the client uses credentials: 'include'. For CSRF, combine SameSite with double-submit tokens or a header-based approach for unsafe methods (POST/PUT/PATCH/DELETE). Rotate sessions periodically.

2) Preferences in localStorage

Namespace keys, validate values, and avoid tight loops of getItem/setItem. Use a small in-memory cache to reduce synchronous hits.

const prefs = { get(key, def) { try { return JSON.parse(localStorage.getItem(`prefs.${key}`)) ?? def; } catch { return def; } }, set(key, val) { localStorage.setItem(`prefs.${key}`, JSON.stringify(val)); } };

3) IndexedDB for data

Think in terms of stores and indexes. Include a version and updatedAt in records to help with reconciliation. Keep writes batched inside a single transaction when you need atomicity across multiple stores.

// read many with a cursor const tx = db.transaction('tasks'); const idx = tx.objectStore('tasks').index('byProject'); const req = idx.openCursor(IDBKeyRange.only('p1')); const results = []; req.onsuccess = () => { const cursor = req.result; if (!cursor) return; results.push(cursor.value); cursor.continue(); };

4) Cache API via a Service Worker

Version cache names on each deploy (e.g., static-v2) and delete old caches during the activate event.

self.addEventListener('activate', event => { const keep = new Set(['static-v2', 'api-cache']); event.waitUntil( caches.keys().then(keys => Promise.all( keys.filter(k => !keep.has(k)).map(k => caches.delete(k)) )) ); });

Data hygiene: expiration, invalidation, and logout

Local caches grow stale unless you prune them. Adopt clear policies:

  • Asset caches: bump cache name per release and delete old.
  • API caches: store a fetchedAt timestamp; revalidate beyond a TTL.
  • IndexedDB: periodically remove records older than a retention policy if they’re not pinned.
  • Logout: explicitly clear localStorage, wipe IndexedDB stores, and delete caches. Relying on users to close tabs is not a policy.
// On logout in the client localStorage.clear(); indexedDB.deleteDatabase('app-db'); caches.keys().then(keys => Promise.all(keys.map(k => caches.delete(k)))); // Server should also expire the session cookie

Performance tips

  • Avoid main-thread jank: localStorage is synchronous; heavy reads/writes can freeze the UI. For serious data, use IndexedDB (async) or a Worker.
  • Batch operations: fewer, larger transactions beat many tiny ones in IndexedDB.
  • Schema evolution: plan IndexedDB upgrades carefully; keep them fast and idempotent.
  • Selective caching: don’t blindly cache every response; cache what improves UX and is safe to reuse.

Testing and debugging

  • DevTools Application tab: inspect cookies, localStorage, IndexedDB, and Cache Storage. Try clearing and simulating storage pressure.
  • Network throttling and offline: verify offline flows and cache hits.
  • Storage events: use the storage event to sync tabs for small values.
window.addEventListener('storage', (e) => { if (e.key === 'prefs.ui') { // another tab updated preferences refreshUI(JSON.parse(e.newValue)); } });

Prompts you can use while building with YeshBe

YeshBe is a platform for building software by describing what you want in natural language and refining through conversation. When you’re ready to wire client-side storage into your app, prompts like these can accelerate the work while keeping the architecture sound.

Set up authentication so the server manages the session with an HttpOnly, Secure, SameSite=Lax cookie. Update all API calls to use credentials: 'include'. Add CSRF protections for unsafe methods.

Add a preferences module that stores theme (dark|light) and table density (comfortable|compact) in localStorage. Validate values on read and fall back to safe defaults.

Create an IndexedDB database named app-db with object stores: projects (key id), tasks (key id, index byProject), comments (key id, index byTask). Include upgrade logic for future schema versions.

Implement a Service Worker that precaches the app shell and uses cache-first for static assets and stale-while-revalidate for GET /api/projects and /api/tasks. Add cache versioning and cleanup on activate.

Add an offline outbox queue in IndexedDB for POST/PUT/PATCH/DELETE operations. When the browser regains connectivity, flush the queue with exponential backoff and display any conflicts for user resolution.

On logout, clear localStorage, delete all IndexedDB stores, remove Cache Storage entries, and instruct the server to expire the session cookie. Provide a loading state while the wipe completes.

Frequently asked design questions

Should I put user profiles in IndexedDB for instant UI?

Yes, if it improves responsiveness, but define a revalidation window and purge logic. Never assume locally cached PII is permanent—plan for eviction and explicit wipes.

Can I cache POST responses in the Cache API?

You can, but it’s uncommon and tricky to invalidate correctly. Prefer caching idempotent GET responses and using an IndexedDB outbox for writes.

What about sessionStorage?

sessionStorage is per-tab and clears on tab close. It’s suitable for ephemeral, non-sensitive state that must not leak between tabs (e.g., a one-time wizard step). It shares the same XSS exposure as localStorage.

Do I need both IndexedDB and the Cache API?

Often, yes: Cache API shines for static assets and GET-able responses, while IndexedDB excels at structured data and write queues. They’re complementary.

A practical checklist

  • Sessions in HttpOnly/Secure/SameSite cookies; never in localStorage.
  • UI prefs in localStorage with defaults and validation.
  • Large/offline data in IndexedDB with indexes, TTLs, and migrations.
  • Static and GET responses in Cache API; version and prune caches.
  • Explicit logout wipes for all storages plus server-side session expiry.
  • Plan for failures: quota exceeded, eviction, and offline fallback UI.
  • Test with DevTools: offline mode, storage inspection, and cache invalidation.

Bringing it together

Great client-side storage is less about APIs and more about fit. Cookies keep your sessions safe, localStorage keeps your UI snappy, IndexedDB keeps your data resilient, and the Cache API keeps your app fast and offline-capable. Start with a data map and a security posture, then add storage where it truly improves the user experience. Whether you code it by hand or describe what you want and iterate with AI in YeshBe, the architecture above will save you from the most common pitfalls and set your app up for speed and reliability.

What will you build first?

Start with one sentence. You can change everything after you see it.

Free to start. No credit card.