When to use Cache API over IndexedDB
Symptom: an offline-first PWA feels sluggish on cold start, storage fills up faster than expected, and DevTools shows large Blob and ArrayBuffer rows inside an IndexedDB database. The root cause is almost always the same architectural mistake — persisting raw network responses (HTML, CSS, JS, fonts, images) directly into IndexedDB instead of the Cache API. Running fetchable assets through IndexedDB triggers structured-clone overhead on every read and write, forces you to invent key-management conventions the platform already provides, and burns quota that the Cache API for Static Assets is purpose-built to manage. This page is a child of that guide; for the foundational primitives behind both stores review Browser Storage Fundamentals & Quotas.
The rule of thumb is short: if the data arrived as — or can be modeled as — an HTTP response and you only ever read it whole, it belongs in the Cache API. If you need to query, index, partially update, or transactionally mutate it, it belongs in IndexedDB. Follow the migration path below to isolate the two concerns and recover offline-first performance.
Root cause: why IndexedDB is the wrong home for assets
IndexedDB and the Cache API sit on opposite sides of a single design decision — how the value is persisted.
Every value written to IndexedDB is copied in through the structured clone algorithm, defined in the HTML Living Standard. Structured clone exists to deep-copy live JavaScript objects — Maps, Sets, typed arrays, Blobs, cyclic graphs — into a form the storage engine can serialize to disk and faithfully reconstruct later. That machinery is exactly what you want for application state. It is pure waste for a 200 KB JavaScript bundle: the bytes get walked, copied, and serialized on write, then deserialized again on every read, all to reproduce a payload the network already handed you fully formed.
The Cache API, specified in the W3C Service Workers standard, takes the other road. It stores Request/Response pairs natively. A Response is already a stream of bytes plus headers, so the cache persists it as-is — no object graph to clone, no serialization step, and the stored entry carries its own Content-Type, Cache-Control, and status. The practical consequences of using the wrong store:
- CPU on the critical path. Structured-clone deserialization runs when you read the asset back — often during page load, the worst possible moment.
- Quota pressure. IndexedDB values and their indexes count against origin quota with serialization overhead; the Cache API accounts the raw response and participates in the browser’s own eviction model.
- Lost HTTP semantics. Store a script as a base64 string and you throw away the headers, then rebuild a
Responseby hand at read time. The Cache API returns a realResponseyou can hand straight toevent.respondWith.
Step 1: Audit and segregate payloads
- Inventory existing storage: query
indexedDB.databases()andcaches.keys()to map every current persistence layer. - Migrate fetchable resources: move all HTML, CSS, JS, fonts, and images to the Cache API.
- Isolate state data: reserve IndexedDB exclusively for structured JSON, user preferences, relational state, and offline transaction logs.
interface StorageInventory {
databases: string[];
caches: string[];
}
async function auditStorage(): Promise<StorageInventory> {
const dbList = (await indexedDB.databases?.()) ?? [];
return {
databases: dbList.map((d) => d.name ?? '(unnamed)'),
caches: await caches.keys(),
};
}
Step 2: Implement the Cache API storage pattern
Store HTTP streams directly with caches.open() and cache.put(). Handle QuotaExceededError explicitly — Cache API limits are dynamic and tied to available disk, so the same code that passes on a desktop can throw on a near-full phone.
async function cacheNetworkResponse(url: string): Promise<Response> {
try {
const cache = await caches.open('app-static-v1');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Fetch failed with HTTP ${response.status}`);
}
// cache.put() consumes the body stream, so clone before storing
// and returning the response to the caller.
await cache.put(url, response.clone());
return response;
} catch (err) {
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
console.error('Cache quota exceeded. Trigger cleanup routine.');
// Implement a cache-eviction strategy here (delete oldest cache version).
} else {
console.error('Cache API write failed:', (err as Error).message);
}
throw err;
}
}
Step 3: Implement IndexedDB state sync
Isolate application state management. Use the native IndexedDB API or a thin promise wrapper such as idb for structured data, and never route asset caching through this layer. The deeper design space for this store — schema migrations, transaction scopes, and indexing — lives in IndexedDB Architecture & Advanced Patterns.
interface SessionState {
[key: string]: unknown;
}
function syncAppState(stateData: SessionState): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('app-state-db', 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('user-prefs')) {
db.createObjectStore('user-prefs', { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const tx = db.transaction('user-prefs', 'readwrite');
tx.objectStore('user-prefs').put({ id: 'active-session', ...stateData });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
request.onerror = (event) =>
reject((event.target as IDBOpenDBRequest).error);
});
}
Step 4: Service worker interception
Route static-asset requests to the Cache API first with a network fallback. Keep IndexedDB completely out of the fetch handler so no structured-clone work ever blocks a page render. These runtime patterns are covered in depth in Service Worker Caching Strategies.
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const STATIC_DESTINATIONS: RequestDestination[] = [
'document',
'script',
'style',
'image',
'font',
'manifest',
];
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
// Only intercept GET requests for static assets.
if (request.method !== 'GET') return;
if (!STATIC_DESTINATIONS.includes(request.destination)) return;
event.respondWith(
caches.match(request).then(async (cached) => {
if (cached) return cached;
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open('app-static-v1');
await cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (err) {
console.warn('Fetch fallback failed:', (err as Error).message);
return new Response(
'Service temporarily offline. Please check your connection.',
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' },
}
);
}
})
);
});
Verification: confirm the split holds
- [ ]
caches.keys()returns only asset-related cache names (e.g.app-static-v1,cdn-assets-v2). - [ ]
indexedDB.databases()contains only structured-state stores (e.g.app-state-db,offline-queue). - [ ] In the DevTools Application tab, expand Cache Storage — your HTML, CSS, JS, and images appear there, not under IndexedDB.
- [ ] Run a Lighthouse Offline audit: static assets return from the Cache API with zero serialization latency.
- [ ] Throttle to Offline in the Network panel and reload — cached routes render; the 503 fallback only appears for uncached requests.
- [ ] Monitor
QuotaExceededErrorin production telemetry so an eviction routine fires before storage limits hit users.
Edge cases and alternative approaches
JSON API payloads are the case where the clean split blurs. They arrive as HTTP responses (which argues for the Cache API) yet often need querying and partial updates (which argues for IndexedDB). For a small, read-whole config blob, caching the Response is fine; for a queryable dataset, deserialize into IndexedDB instead. That specific trade-off is worked through in IndexedDB vs Cache API for Offline JSON Payloads.
Safari eviction is the second edge case. WebKit can purge the Cache API after roughly seven days of inactivity, so a guaranteed-offline route should keep a lightweight IndexedDB manifest of expected cache keys. On startup, compare the manifest against caches.keys(); if assets are missing, re-precache them. This fallback uses IndexedDB only for tiny metadata — never the assets themselves — so the separation of concerns survives.
async function reconcileManifest(expectedKeys: string[]): Promise<string[]> {
const cache = await caches.open('app-static-v1');
const present = new Set(
(await cache.keys()).map((req) => new URL(req.url).pathname)
);
// Return the assets Safari evicted so the caller can re-precache them.
return expectedKeys.filter((key) => !present.has(key));
}
Frequently Asked Questions
Can I store images in IndexedDB instead of the Cache API?
You can store image Blobs in IndexedDB, but for assets fetched over HTTP the Cache API is the better fit: it persists the Response stream without the structured-clone cost and integrates natively with service worker fetch interception. Reserve IndexedDB blobs for images you generate or transform client-side and need to query by metadata.
Why is IndexedDB slower for static assets?
Every IndexedDB write runs the value through the structured clone algorithm, which deep-copies and serializes it, and every read deserializes it back. For large binary or text assets this burns CPU and adds latency on the critical path. The Cache API stores the raw response bytes directly, so there is no transformation step at all.
Should the service worker fetch handler ever touch IndexedDB?
Keep IndexedDB out of the hot fetch path. Asset responses should resolve from the Cache API so they return instantly. IndexedDB work — reading state, draining a sync queue — belongs in message handlers, background sync events, or app code, not in the request that is blocking a page render.
Related
- Cache API for Static Assets — the parent guide to precaching, quota, and cache hygiene.
- IndexedDB vs Cache API for Offline JSON Payloads — resolving the JSON edge case.
- IndexedDB Architecture & Advanced Patterns — schemas, transactions, and indexing for the state layer.
- Service Worker Caching Strategies — the runtime strategies that build on this split.
- Browser Storage Fundamentals & Quotas — the foundational primitives behind both stores.