JavaScript IndexedDB Table
| Piece | What it does | Field note |
|---|---|---|
indexedDB.open(name, version) | The entry | Async request; bumping version fires onupgradeneeded exactly once per version |
onupgradeneeded | The schema gate | createObjectStore/createIndex live ONLY here; branch on oldVersion = your migration chain |
transaction(mode) | The unit of work | readonly/readwrite; auto-commits when the task yields - no awaits inside |
objectStore + keyPath | The tables | In-line keys via keyPath, out-of-line via explicit keys, autoIncrement for counters |
createIndex / .index() | The queries | Query non-key fields via get/getAll/openCursor - no index, no WHERE |
structured clone | What fits | Objects, Maps, Sets, Dates, Blobs clone; functions, prototypes, DOM nodes do not |
request.onsuccess | The ergonomics | One event per call - promisify or adopt the idb wrapper for await-style code |
storage.persist()/estimate() | The durability | Best-effort eviction by default; persist() opts out; estimate() reports the quota |
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
- 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.
- 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.
- 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.
- 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.
- 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.