JavaScript IndexedDB Table

PieceWhat it doesField note
indexedDB.open(name, version)The entryAsync request; bumping version fires onupgradeneeded exactly once per version
onupgradeneededThe schema gatecreateObjectStore/createIndex live ONLY here; branch on oldVersion = your migration chain
transaction(mode)The unit of workreadonly/readwrite; auto-commits when the task yields - no awaits inside
objectStore + keyPathThe tablesIn-line keys via keyPath, out-of-line via explicit keys, autoIncrement for counters
createIndex / .index()The queriesQuery non-key fields via get/getAll/openCursor - no index, no WHERE
structured cloneWhat fitsObjects, Maps, Sets, Dates, Blobs clone; functions, prototypes, DOM nodes do not
request.onsuccessThe ergonomicsOne event per call - promisify or adopt the idb wrapper for await-style code
storage.persist()/estimate()The durabilityBest-effort eviction by default; persist() opts out; estimate() reports the quota
Reference: the MDN IndexedDB API guide and the IDBDatabase.transaction() method. IndexedDB is the storage layer of every offline-first app: transactional, indexed, hundreds of megabytes per origin, and asynchronous end to end - every operation returns an IDBRequest, which is why the ecosystem standardized on promise wrappers within a year of the API shipping.
Bottom line: one gate, one trap. The gate - schema changes only happen in onupgradeneeded, once per version bump, so the versioned migration chain is a day-one decision. The trap - a transaction commits the moment your task yields to the event loop, so an await inside splits your writes across a dead transaction. Add the honesty clause: structured clone strips prototypes (store data with a type tag, rehydrate on read), and the whole origin is best-effort storage the browser may evict - persist() for exemptions, the server as source of truth.
Related tools: the localStorage table (the small synchronous sibling), the structured clone table (the copy semantics behind what stores), the Blob table (the binary payloads you put in), and the Web Locks table (coordinating multi-tab access to one database).

IndexedDB is the browser's built-in transactional database: asynchronous, origin-scoped, sized in hundreds of megabytes where localStorage stops at ~5MB of strings. It stores structured data - objects, Maps, ArrayBuffers, Blobs - with real indexes for queries, and it is the storage layer under every offline-first app, usually paired with a service worker and, for large binary files, the Origin Private File System.

Bottom line: the whole API has one gate and one trap. The gate: schema changes (createObjectStore, createIndex) live ONLY inside onupgradeneeded, which fires once per version bump of indexedDB.open(name, version) - there is no ALTER TABLE, so a migration chain branched on oldVersion is a day-one design decision, not a retrofit. The trap: transactions auto-commit the moment your task yields to the event loop - await a fetch between two puts and the second one lands on a dead transaction. Keep each transaction's work synchronous, and open a fresh one after any await.

The honest part: IndexedDB stores DATA, not objects. Structured clone copies values - so your class instances come back as plain objects with no methods and no prototype, while Dates, Maps, Sets, and Blobs survive intact. And it is best-effort storage: under disk pressure the browser may evict your origin's data unless you have asked for persistence with navigator.storage.persist(). The server remains the source of truth; IndexedDB is the cache you can query.

How to use

  1. Open and upgrade: const req = indexedDB.open('app', 2); req.onupgradeneeded = (e) => { if (e.oldVersion < 1) db.createObjectStore('notes', { keyPath: 'id' }); if (e.oldVersion < 2) notes.createIndex('by-tag', 'tag'); }; req.onsuccess = () => db = req.result; - the version bumps trigger the upgrade exactly once, and branching on oldVersion makes every migration step idempotent.
  2. Read and write through transactions: every operation runs inside one - db.transaction('notes', 'readwrite').objectStore('notes').put(note) returns an IDBRequest whose onsuccess carries the result. Use readonly transactions wherever possible: the browser can run those in parallel, and the mode declaration documents intent.
  3. Index your queries: querying by a non-key field needs an index created in onupgradeneeded - store.index('by-tag').getAll('urgent') or openCursor with an IDBKeyRange for pagination. There is no WHERE without an index; scanning every object by hand in JS defeats the point of the database.
  4. Wrap requests in promises: the raw API is event-per-call; a 15-line promisifier or the idb wrapper library gives you await-able calls. Just keep the auto-commit rule in mind even with a wrapper - one synchronous batch per transaction, then open the next transaction after the await.
  5. Know your quotas: navigator.storage.estimate() reports usage and quota; navigator.storage.persist() asks the browser to exempt your origin from eviction under disk pressure. Store large files as Blobs, stream really large ones through OPFS, and treat everything client-side as reconstructable - sync-first architectures lose nothing when a browser wipes an origin.

Frequently asked questions

IndexedDB vs localStorage - when do I use which?

localStorage: synchronous, strings only, ~5MB, blocks the main thread on every access. IndexedDB: asynchronous, structured data, hundreds of MB, transactional, indexable. localStorage remains fine for preferences and tiny state (theme, locale); the moment you store user-generated data, offline documents, or anything binary, IndexedDB is the tool - synchronous JSON round-trips of a few hundred KB through localStorage are a main-thread stall users feel as jank. The modern third lane: the Origin Private File System (OPFS) for large binaries with random access, which outperforms IndexedDB blobs for media and big datasets.

Why does my second put silently vanish?

The auto-commit trap: a transaction commits as soon as your task returns to the event loop. Awaiting anything inside - fetch, a timer promise, another request wrapper - ends the transaction; the next put either throws TransactionInactiveError or quietly goes nowhere depending on timing, which is why it survives testing and dies in production. The fix is structural: gather all data first (await freely), then open the transaction and run every put synchronously. Promisified wrappers hide this rule but do not repeal it.

Can I store class instances or functions in IndexedDB?

No - and this is by design: structured clone copies data, not behavior. Functions, DOM nodes, and prototypes do not survive; a class instance comes back as a plain object literal with the same fields and no methods. Dates, Maps, Sets, ArrayBuffers, and Blobs clone natively. The pattern that works: store plain data with a type tag ({ type: 'Point', x: 1, y: 2 }) and rehydrate through a fromJSON factory on read - the serialization boundary is explicit and versioned instead of leaking your class layout into permanent storage.

What happens when the user's storage fills up?

IndexedDB is best-effort by default: under disk pressure the browser may evict data from non-persisted origins, choosing least-recently-used across sites - your offline data can vanish without warning. navigator.storage.persist() requests an exemption (granted more readily for installed PWAs and frequently-used origins), and estimate() tells you where you stand. Plan for the wipe: anything the user would grieve losing syncs to the server; IndexedDB is the local copy that makes the app fast offline, not the archive. Safari's older 7-day script-writable-storage eviction was the industry's loud reminder of this hierarchy.

Related tools