Skip to content

Storage · Concept

IndexedDB: structured client-side storage for PWAs

Published

In one line: IndexedDB is the browser’s transactional, asynchronous database for structured data — JavaScript objects, files, and blobs — keyed and indexed for lookup. It is the right store for a PWA’s offline app data: far larger and richer than localStorage, works inside service workers, and survives across sessions subject to the origin’s storage quota.

  • localStorage is a synchronous, string-keyed/string-valued store meant for small amounts of data — fine for a few flags, wrong for app data, and it’s not exposed to service workers.
  • Cache Storage stores Request/Response pairs for offline assets — the app shell, not your records.
  • IndexedDB holds structured records and binary blobs, is asynchronous (never blocks the main thread), and is reachable from both windows and service workers — the home for offline drafts, queued mutations, cached API data, and large media.
  • Database — named, versioned, scoped to a single origin.
  • Object store — like a table; holds records (any structured-cloneable value) keyed by a key path or generated key.
  • Index — a secondary lookup over a property of the stored records, so you can query by something other than the primary key.
  • Transaction — every read and write happens inside one, scoped to named stores and a mode (readonly / readwrite). It commits automatically once its associated requests have completed and control returns to the event loop (you don’t call commit() in normal use).
  • Request — each operation returns an IDBRequest that fires onsuccess / onerror; the result arrives on the event, not as a return value.

The native API is event-based, which is its sharpest edge. A common gotcha is that a transaction stays alive only while it has pending requests — await-ing an unrelated promise between operations lets control return to the event loop and the transaction commit out from under you.

const open = indexedDB.open('app', 2);
open.onupgradeneeded = (event) => {
const db = event.target.result;
// Schema changes are ONLY legal here, inside a versionchange transaction.
if (!db.objectStoreNames.contains('drafts')) {
const store = db.createObjectStore('drafts', { keyPath: 'id' });
store.createIndex('byUpdated', 'updatedAt');
}
};
open.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction('drafts', 'readwrite');
tx.objectStore('drafts').put({ id: 'd1', updatedAt: Date.now(), body: '…' });
tx.oncomplete = () => console.log('saved');
tx.onerror = () => console.error('write failed', tx.error);
};

The version number is how IndexedDB does migrations. Opening with a higher version than the stored one fires onupgradeneeded, and that callback is the only place you may create or delete object stores and indexes. Key rules:

  • Bump the version when the schema changes; branch on event.oldVersion to apply each migration step in order.
  • A versionchange upgrade is blocked while other tabs hold the database open at the old version — listen for the blocked/versionchange events and close stale connections so the upgrade can proceed.
  • Schema work is forbidden in ordinary readwrite transactions; attempting it throws.

IndexedDB shares the single per-origin storage quota the Storage API governs, alongside Cache Storage and others. By default storage is best-effort and can be cleared under storage pressure, so durability-critical data should pair IndexedDB with a navigator.storage.persist() request to ask for persistent storage. Wrap writes to catch QuotaExceededError, then prune stale data and retry rather than failing hard. (See the storage persistence reference for the full quota and eviction model.)

IndexedDB is supported across current major browsers and is reachable from both windows and service workers, making it the baseline durable store for offline-capable PWAs. Because the raw event API is verbose and easy to misuse, many apps wrap it in a thin promise-based layer — the cited web.dev guide uses the idb library, which simplifies the API while keeping the same underlying semantics described here.

  • Use IndexedDB for structured/offline data; reserve localStorage for tiny synchronous flags.
  • Do all createObjectStore/createIndex work inside onupgradeneeded, keyed off the version.
  • Don’t await unrelated promises mid-transaction — it lets the transaction auto-commit early.
  • Handle onerror on requests and transactions; surface failures instead of dropping writes.
  • Define indexes for the queries you actually run, rather than scanning every record.
  • Catch QuotaExceededError, prune, and retry; call persist() for durability-critical data.
  • Consider a small promise-based wrapper like idb to avoid re-implementing the event API.

Specifications

SpecificationStatus
None.