When to use Cache API over IndexedDB

Frontend engineers and PWA developers frequently misapply storage architecture by persisting raw network responses (HTML, CSS, JS, images) directly into IndexedDB. This architectural mismatch triggers severe structured-cloning overhead, forces complex key management, and exhausts storage limits prematurely. This page sits under the Cache API for Static Assets guide and within Browser Storage Fundamentals & Quotas; read those for the surrounding context. IndexedDB requires data serialization, adding unnecessary CPU cycles for binary and text assets. Conversely, the Cache API is purpose-built to intercept HTTP streams, store Request/Response pairs natively, and bypass transformation entirely.

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. The IndexedDB structured clone algorithm exists to deep-copy live JavaScript objects into a durable store; running raw asset bytes through it wastes CPU and disk. The Cache API skips that step entirely because it persists the Response stream as-is. Follow this migration path to isolate concerns and optimize offline-first performance.

Step 1: Audit & Segregate Payloads

  1. Inventory existing storage: Query indexedDB.databases() and caches.keys() to map current persistence layers.
  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.

Step 2: Implement Cache API Storage Pattern

Store HTTP streams directly using caches.open() and cache.put(). Handle QuotaExceededError explicitly, as browser cache limits are dynamic and tied to available disk space.

async function cacheNetworkResponse(url) {
  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 API consumes the response body; clone before storing
    // and returning to the caller.
    await cache.put(url, response.clone());
    return response;
  } catch (err) {
    // Handle explicit quota limits or network failures
    if (err.name === 'QuotaExceededError') {
      console.error('Cache quota exceeded. Trigger cleanup routine.');
      // Implement cache eviction strategy here
    } else {
      console.error('Cache API write failed:', err.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. Never route asset caching through this layer.

async function syncAppState(stateData) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('app-state-db', 1);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('user-prefs')) {
        db.createObjectStore('user-prefs', { keyPath: 'id' });
      }
    };

    request.onsuccess = (event) => {
      const db = event.target.result;
      const tx = db.transaction('user-prefs', 'readwrite');
      const store = tx.objectStore('user-prefs');

      store.put({ id: 'active-session', ...stateData });

      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    };

    request.onerror = (event) => reject(event.target.error);
  });
}

The deeper design space for this layer — schema migrations, transactions, and indexing — lives in IndexedDB Architecture & Advanced Patterns.

Step 4: Service Worker Interception

Route static asset requests to the Cache API first. Implement a network fallback. Keep IndexedDB completely out of the fetch handler to prevent main-thread blocking and serialization delays.

self.addEventListener('fetch', (event) => {
  // Only intercept GET requests for static assets
  if (event.request.method !== 'GET') return;

  const isStaticAsset = [
    'document',
    'script',
    'style',
    'image',
    'font',
    'manifest',
  ].includes(event.request.destination);

  if (isStaticAsset) {
    event.respondWith(
      caches
        .match(event.request)
        .then((cached) => {
          if (cached) return cached;
          return fetch(event.request).then((networkResponse) => {
            // Clone and cache successful network responses
            if (networkResponse.ok) {
              const cacheClone = networkResponse.clone();
              caches
                .open('app-static-v1')
                .then((cache) => cache.put(event.request, cacheClone));
            }
            return networkResponse;
          });
        })
        .catch((err) => {
          console.warn('Fetch fallback failed:', err.message);
          // Return a valid offline fallback response
          return new Response(
            'Service temporarily offline. Please check your connection.',
            {
              status: 503,
              statusText: 'Service Unavailable',
              headers: new Headers({ 'Content-Type': 'text/plain' }),
            }
          );
        })
    );
  }
});

Verification Checklist

Edge Cases & Fallback Approach

The clean split above breaks down for JSON API payloads, which arrive as HTTP responses (suggesting the Cache API) yet often need querying and partial updates (suggesting 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.

A second edge case is Safari eviction: because WebKit can purge the Cache API after roughly 7 days of inactivity, 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 for the assets themselves, preserving the separation of concerns.

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