home/
post/
cache storage in ui

Cache Storage in UI

Oct 27, 2025
19 min
2 charts

This post isn’t about caching strategies like cache-aside or cache-through.

It’s about storage — where can I keep my cache in the browser, and what trade-offs come with each option?

Why does this matter?

Because where you store the cache affects your app’s architecture, performance, and security. If you’re designing a new system, this choice can even shape how your frontend interacts with the backend.


Cache Storage Characteristics

To make the options easier to compare, let’s define key characteristics as questions:

  • Persistence — Can the data survive page reloads or browser restarts?

  • Sync vs. async — Does the API return values directly or via Promise?

  • Capacity — How much data can we store before hitting limits?

  • Security — How exposed is the data to JavaScript or potential attacks?

  • Shared across tabs — Can multiple tabs see the same cache?

  • Isolated per tab — Is each tab sandboxed with its own cache?

  • Performance — How fast are reads and writes under normal load?

  • API accessibility — From where in the runtime can the API be used?

  • Offline availability — Is the data available without a network connection?


Cache Storage Options

In-Memory Cache

The simplest option is an in-memory cache — data stored in a JavaScript Map (or WeakMap) that will be empty when the tab reloads.

You can store any structure — objects, classes, arrays — and you can control expiration manually.

Check out example:

class MemoryCache { constructor({ ttlMs = 60_000 } = {}) { this.ttlMs = ttlMs; this.map = new Map(); } _now() { return Date.now(); } set(key, value, ttl = this.ttlMs) { this.map.set(key, { value, expiresAt: ttl ? this._now() + ttl : null }); } get(key) { const e = this.map.get(key); if (!e) return; if (e.expiresAt && e.expiresAt < this._now()) { this.map.delete(key); return; } return e.value; } has(k) { return this.get(k) !== undefined; } delete(k) { this.map.delete(k); } clear() { this.map.clear(); } } // Example const cache = new MemoryCache({ ttlMs: 5 * 60_000 }); cache.set('user:123', { id: 123, name: 'Ada' }); console.log(cache.get('user:123')); // { id: 123, name: 'Ada' }

In-Memory Cache in a Web Worker

This is similar to the previous approach but runs inside a Web Worker, which isolates cache memory from the main thread.

It’s like a locked drawer managed by an assistant — main scripts can’t access directly inside; they have to ask via messages.

However, this isn’t full protection against XSS. If an attacker can run JavaScript in your page, they can still message the worker and steal data unless you authenticate requests.

// cache.worker.js const cache = new MemoryCache(); self.onmessage = ({ data }) => { const { id, op, key, value, ttl, opts } = data; if (op === 'init') Object.assign(cache, new MemoryCache(opts)); let result; switch (op) { case 'set': cache.set(key, value, ttl); result = true; break; case 'get': result = cache.get(key); break; case 'has': result = cache.has(key); break; case 'delete': cache.delete(key); result = true; break; case 'clear': cache.clear(); result = true; break; default: result = null; } postMessage({ id, result }); };
// main thread const worker = new Worker(new URL('./cache.worker.js', import.meta.url), { type: 'module' }); let nextId = 1; const pending = new Map(); worker.onmessage = e => { const { id, result } = e.data || {}; if (!pending.has(id)) return; pending.get(id).resolve(result); pending.delete(id); }; function call(op, data = {}) { return new Promise(resolve => { const id = nextId++; pending.set(id, { resolve }); worker.postMessage({ id, op, ...data }); }); } // Example await call('init', { opts: { ttlMs: 5 * 60_000 } }); await call('set', { key: 'user:123', value: { id: 123, name: 'Ada' } }); console.log(await call('get', { key: 'user:123' })); // { id: 123, name: 'Ada' }

In-Memory Cache in a Shared Worker

A Shared Worker extends the Web Worker idea by allowing multiple tabs (from the same origin) to share one worker and its memory.

It’s like a hallway locker that several rooms can access asynchronously.

Shared Workers are not supported in all browsers. Consider BroadcastChannel or Service Workers as fallbacks.

// shared-worker.js const cache = new MemoryCache(); onconnect = event => { const port = event.ports[0]; port.start(); port.onmessage = ({ data }) => { const { op, key, value, ttl, opts } = data; let result; switch (op) { case 'init': Object.assign(cache, new MemoryCache(opts)); result = true; break; case 'set': cache.set(key, value, ttl); result = true; break; case 'get': result = cache.get(key); break; case 'has': result = cache.has(key); break; case 'delete': cache.delete(key); result = true; break; case 'clear': cache.clear(); result = true; break; default: result = null; } port.postMessage(result); }; };
// main thread (any tab) const worker = new SharedWorker(new URL('./cache.shared.js', import.meta.url), { type: 'module' }); const port = worker.port; port.start(); function call(op, data = {}) { return new Promise(resolve => { port.onmessage = e => resolve(e.data); port.postMessage({ op, ...data }); }); } // Example usage await call('init', { opts: { ttlMs: 5 * 60_000 } }); await call('set', { key: 'user:123', value: { id: 123, name: 'Ada' } }); console.log(await call('get', { key: 'user:123' })); // { id: 123, name: 'Ada' }

Local Storage

Local Storage is like a sticky-note pad — small, simple, and persistent between reloads, but visible to any script on the same site.

  • Data limit: ~5–10 MB (varies by browser).

  • Keys/values: strings only.

  • Scope: per origin (not shared between different sites).

  • Security: accessible by any script on the same origin. Never store secrets here.

class LocalStorageCache { constructor({ prefix = 'cache:', ttlMs = 60_000 } = {}) { this.prefix = prefix; this.ttlMs = ttlMs; } _now() { return Date.now(); } _key(key) { return this.prefix + key; } set(key, value, ttl = this.ttlMs) { const expiresAt = ttl ? this._now() + ttl : null; // null = no expiry const record = { value, expiresAt }; localStorage.setItem(this._key(key), JSON.stringify(record)); } get(key) { const raw = localStorage.getItem(this._key(key)); if (!raw) return; try { const record = JSON.parse(raw); if (record.expiresAt != null && record.expiresAt < this._now()) { localStorage.removeItem(this._key(key)); return; } return record.value; } catch { localStorage.removeItem(this._key(key)); // corrupted entry } } has(key) { return this.get(key) !== undefined; } delete(key) { localStorage.removeItem(this._key(key)); } clear() { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith(this.prefix)) localStorage.removeItem(k); } } }

Use this for low-volume, non-sensitive, global data such as user preferences.


Session Storage

Session Storage works like Local Storage but with two key differences:

  • Data is wiped when the tab or browser closes (short-lived).

  • Each tab gets its own isolated copy.

It’s good for temporary data (form state, navigation cache) but still vulnerable to XSS.


Cache API

The Cache API is a persistent, async key–value store designed for HTTP requests and responses.

Think of it as a pantry for packaged goods — great for caching network assets, but not ideal for arbitrary data.

  • Persistence: On disk; survives reloads and restarts.

  • API shape: Promise-based. Works with Request and Response objects.

  • Capacity: Large, browser-managed (may evict under pressure).

  • Security: Same-origin; avoid storing secrets in plain text.

  • Shared across tabs: Yes, per origin.

  • Best for: Offline assets, prefetching, API response caching.

// Minimal Cache API wrapper with stale-while-revalidate async function cacheFetch(request, { cacheName = 'app-v1', revalidate = true } = {}) { const cache = await caches.open(cacheName); const req = typeof request === 'string' ? new Request(request) : request; const cached = await cache.match(req); if (cached) { if (revalidate) { fetch(req) .then(res => { if (res.ok) cache.put(req, res.clone()); }) .catch(() => {}); } return cached.clone(); } const res = await fetch(req); if (res.ok) await cache.put(req, res.clone()); return res; } // Usage const res = await cacheFetch('/api/users/1'); const data = await res.json(); console.log(data);

Further reading: MDN Cache API


IndexedDB

IndexedDB is the browser’s built-in NoSQL database — powerful, persistent, and async.

Imagine a warehouse with labeled shelves — slower to reach but can hold huge, organized collections.

  • Persistence: On disk; survives reloads and restarts.

  • API shape: Promise-based (with wrappers).

  • Capacity: Tens to hundreds of MBs, depending on browser and user settings.

  • Security: Same-origin; still accessible to any same-origin script.

  • Shared across tabs: Yes.

  • Use cases: Large datasets, offline apps, queues, sync replicas.

// idb.js — minimal helper function openDB(name, version, upgrade) { return new Promise((resolve, reject) => { const req = indexedDB.open(name, version); req.onupgradeneeded = e => upgrade(req.result, e.oldVersion, e.newVersion); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function tx(db, store, mode, fn) { return new Promise((resolve, reject) => { const t = db.transaction(store, mode); const s = t.objectStore(store); const request = fn(s); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // One-time setup const db = await openDB('app-db', 1, db => { if (!db.objectStoreNames.contains('users')) { const s = db.createObjectStore('users', { keyPath: 'id' }); s.createIndex('by_name', 'name'); } }); // Put and get await tx(db, 'users', 'readwrite', s => s.put({ id: 1, name: 'Ada' })); const user = await tx(db, 'users', 'readonly', s => s.get(1)); console.log(user);

Browser Cache Options — Comparison Table

CharacteristicIn-Memory (Tab)Web WorkerShared WorkerSession StorageLocal StorageCache APIIndexedDB
Persistence
API Sync?
Shared Across Tabs✅ (per origin)
CapacityRAM-boundRAM-boundRAM-boundSmall~5–10 MBMedium/LargeLarge
Relative Speed★★★★★★★★★★★★★★
Security NotesOrigin JS accessNeeds API authNeeds API authSameSameAvoid secretsAvoid secrets
Offline Availability
Best ForHot ephemeral dataIsolated hot cacheMulti-tab ephemeral cachePer-tab temp dataSmall settingsHTTP/asset cacheComplex/large data

Choosing the Right One

  • Just need fast temporary lookups in one tab? → In-memory.

  • Want isolation from the main thread? → Web Worker.

  • Need multi-tab sharing? → Shared Worker or BroadcastChannel.

  • Small, non-sensitive persistent data? → Local Storage.

  • Short-lived tab data? → Session Storage.

  • Cache fetch responses or assets offline? → Cache API.

  • Need structured, large, or relational data offline? → IndexedDB.

Each browser storage mechanism is a trade-off between speed, persistence, and isolation.

Think of them as layers:

  • RAM caches (fast, temporary)

  • Web/Shared Workers (isolated, async, optional sharing)

  • Web Storage (Local/Session) (simple, limited)

  • Cache API (network-oriented, persistent)

  • IndexedDB (structured, large-scale persistence)

Choose the one that matches your data lifetime and security model, not just convenience.

Related Posts
© 2025 buzzchart.info