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.

Decision tree: Cache API versus IndexedDB A decision tree that routes fetchable HTTP assets to the Cache API and structured queryable state to IndexedDB. What are you storing? decide per payload type Fetchable HTTP asset HTML, CSS, JS, fonts, images Structured queryable state JSON records, prefs, queues Cache API IndexedDB

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:

How each store persists an asset: transform cost versus raw passthrough Two pipelines compared. The IndexedDB path runs an asset through the structured clone algorithm on write and again on read, so deserialization lands on the page-load critical path. The Cache API path stores the response bytes as-is and returns a real Response with no transform on either write or read. IndexedDB — the asset is transformed twice Response / object app hands it in Serialized copy written to disk Rebuilt value in memory serialize deserialize runs during page load — CPU on the critical path Cache API — the response is stored and returned as-is Response bytes + headers Raw bytes on disk Content-Type kept Real Response to respondWith store as-is return as-is no clone, no serialization — nothing runs on read

Step 1: Audit and segregate payloads

  1. Inventory existing storage: query indexedDB.databases() and caches.keys() to map every current persistence layer.
  2. Migrate fetchable resources: move all HTML, CSS, JS, fonts, and images to the Cache API.
  3. 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

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