IndexedDB vs Cache API for Offline JSON Payloads

When an offline-first app needs API responses available without a network, two native stores compete: the Cache API and IndexedDB. They look interchangeable — both persist JSON, both survive offline — but they model the data differently. The Cache API stores whole Response objects keyed by Request, which is perfect for replaying a GET inside a service worker but coarse: you cannot query inside the body. IndexedDB stores structured records you can index, query, partially update, and merge. Picking the wrong one forces awkward workarounds later. For foundational context review Cache API for Static Assets, and for the broader decision see When to Use the Cache API over IndexedDB.

Cache API replays responses while IndexedDB queries records A diagram contrasting the Cache API storing whole Response objects keyed by Request against IndexedDB storing queryable structured records. GET /api/notes Cache API Request → whole Response replay GET in fetch handler · no querying cache.match(request) → Response IndexedDB key → structured record index · query · partial update · merge index.getAll(range) → records Replay requests → Cache API · Query & mutate data → IndexedDB

The decision in one sentence

Use the Cache API when you want to replay an HTTP request/response pair offline — typically inside a service worker fetch handler — and you treat the JSON body as an opaque blob. Use IndexedDB when you need to read into the data: query by field, paginate, merge a server delta into a local record, or mutate a single field without rewriting the whole payload. The Cache API is request-shaped; IndexedDB is record-shaped.

How each store models the same payload

A response to GET /api/notes is, to the Cache API, a single entry: the Request is the key and the Response (status, headers, and body stream) is the value. You can hand that Response straight back to the browser, which is exactly what a service worker needs. What you cannot do is ask “give me the notes tagged urgent” — the Cache API has no view inside the body.

IndexedDB sees the same payload as a set of records. You parse the JSON once, store each note under its id, and declare indexes on the fields you query. Now “notes tagged urgent ordered by updatedAt” is a single index range scan, and editing one note rewrites one record, not the whole list.

Dimension Cache API IndexedDB
Keyed by Request (URL + varying headers) Arbitrary key path / key
Stored value Whole Response (headers + body) Structured JS values (clone algorithm)
Query inside data No — opaque body Yes — indexes, key ranges, cursors
Partial update No — replace the whole entry Yes — put one record
Merge server delta Awkward (re-fetch + overwrite) Natural (read, merge, write record)
Service worker fit Excellent — return Response directly Usable, but you must rebuild a Response
Capacity Large (shares origin quota) Large (shares origin quota)
Ergonomics Trivial for whole-response replay More code, but queryable

Pattern 1: caching a JSON GET with the Cache API

Inside a service worker, a stale-while-revalidate handler returns the cached Response instantly and refreshes it in the background. The body is never parsed — it flows straight through. This is the model detailed in Service Worker Caching Strategies.

/// <reference lib="webworker" />
const sw = self as unknown as ServiceWorkerGlobalScope;
const JSON_CACHE = 'api-json-v1';

sw.addEventListener('fetch', (event: FetchEvent) => {
  const url = new URL(event.request.url);
  if (event.request.method !== 'GET' || !url.pathname.startsWith('/api/')) return;

  event.respondWith(staleWhileRevalidate(event.request));
});

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(JSON_CACHE);
  const cached = await cache.match(request);

  // Kick off a background refresh; only cache 2xx JSON responses.
  const network = fetch(request)
    .then(async (response) => {
      if (response.ok && response.headers.get('content-type')?.includes('json')) {
        await cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => cached ?? Response.error());

  // Serve cache immediately when present, else wait for the network.
  return cached ?? network;
}

The Cache API shines here: zero parsing, the exact Response the page expected, and a one-line cache.put. But if a single note changes on the server, you must re-fetch and overwrite the entire /api/notes entry — there is no way to patch one note inside it.

Pattern 2: storing parsed JSON in IndexedDB with an index

When the app needs to query or mutate the data, parse it once into IndexedDB and declare an index. Now reads are field-scoped and writes are per-record.

interface Note {
  id: string;
  tag: string;
  body: string;
  updatedAt: number;
}

function openDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('notes-db', 1);
    req.onupgradeneeded = () => {
      const store = req.result.createObjectStore('notes', { keyPath: 'id' });
      store.createIndex('by_tag', 'tag', { unique: false });
      store.createIndex('by_updated', 'updatedAt', { unique: false });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

// Hydrate IndexedDB from a fetched JSON array (parse once).
async function ingest(notes: Note[]): Promise<void> {
  const db = await openDb();
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    for (const note of notes) store.put(note); // upsert each record
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

// Query inside the data — impossible with the Cache API.
async function notesByTag(tag: string): Promise<Note[]> {
  const db = await openDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('notes', 'readonly');
    const idx = tx.objectStore('notes').index('by_tag');
    const req = idx.getAll(IDBKeyRange.only(tag));
    req.onsuccess = () => resolve(req.result as Note[]);
    req.onerror = () => reject(req.error);
  });
}

// Mutate one record without rewriting the whole payload.
async function touchNote(id: string, body: string): Promise<void> {
  const db = await openDb();
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction('notes', 'readwrite');
    const store = tx.objectStore('notes');
    const get = store.get(id);
    get.onsuccess = () => {
      const note = get.result as Note | undefined;
      if (note) store.put({ ...note, body, updatedAt: Date.now() });
    };
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

This costs more code than cache.put, but it unlocks indexed reads, single-record writes, and clean delta merges. For deeper indexing technique see Indexing Strategies for Fast Queries.

Verification

Confirm each store holds what you expect. In DevTools, the Application panel lists Cache Storage entries by request URL and IndexedDB databases by object store. An assertion check:

// Cache API: the entry is keyed by request, body is opaque here.
const cache = await caches.open('api-json-v1');
const hit = await cache.match('/api/notes');
console.assert(hit instanceof Response, 'Expected a cached Response');

// IndexedDB: the data is queryable by index.
const urgent = await notesByTag('urgent');
console.assert(Array.isArray(urgent), 'Expected a queried record array');

Edge cases and a hybrid approach

Hybrid: cache the response, index the data. Many apps use both. The service worker caches /api/notes with the Cache API for instant first paint and offline navigation, while the page also ingests the parsed array into IndexedDB for filtering and editing. Treat the Cache API copy as the network replay and IndexedDB as the queryable working set; reconcile them when the background revalidation completes.

Cache invalidation. The Cache API has no built-in expiry — a stale entry lives until you overwrite or delete it. Version your cache name (api-json-v1v2) and purge old caches in the service worker activate event, or store an updatedAt alongside IndexedDB records and discard the cache entry when the server reports a newer version. After account switching, purge per-user caches so one user’s authorized response cannot replay for another.

Vary headers. If responses differ by Accept-Language or auth, the Cache API may serve the wrong variant unless you set ignoreVary deliberately or include the distinguishing header in the cache key. IndexedDB sidesteps this because you control the key path explicitly.

Frequently Asked Questions

Can I query inside JSON stored in the Cache API?

No. The Cache API stores the whole Response keyed by Request and treats the body as opaque. If you need to filter, sort, or read individual fields, parse the JSON into IndexedDB and declare an index, as shown in Indexing Strategies for Fast Queries.

Which is better for a service worker offline fallback?

The Cache API, because you can return the stored Response directly from a fetch handler with no reconstruction. IndexedDB works too, but you must rebuild a Response object from the stored data. See When to Use the Cache API over IndexedDB.

How do I expire cached JSON in the Cache API?

The Cache API has no automatic expiry. Version the cache name and delete old versions in the service worker activate event, or track an updatedAt value and overwrite the entry when the server reports newer data. Purge per-user caches on logout.

Related