IndexedDB

TL;DR / IndexedDB is a browser-native transactional object store for large structured data, offering indexed queries and multi-megabyte storage without network round-trips.

How It Works

 ┌────────────┐        ┌───────────────┐
 │   JS App   │───────→│ IndexedDB API │
 └────────────┘        └───────────────┘
                               │
         ┌─────────────────────└─────┐
         │                           │
         ↓                           ↓
 ┌──────────────┐            ┌──────────────┐
 │ Transaction  │            │ Transaction  │
 │ (readwrite)  │            │  (readonly)  │
 └──────────────┘            └──────────────┘
         │                           │
         └┐                         ┌┘
          │                         │
          ↓                         ↓
 ┌────────────────┐        ┌────────────────┐
 │ Object Store A │        │ Object Store B │
 └────────────────┘        └────────────────┘
   index   index             index   index

Edit diagram

IndexedDB is the only browser storage API designed for structured data at scale. LocalStorage gives you 5-10MB of synchronous string key-value pairs. The Cache API stores HTTP request/response pairs. IndexedDB stores JavaScript objects with indexes, cursors, and ACID transactions, with storage quotas typically in the hundreds of megabytes to gigabytes range depending on available disk space.

Database Structure

An IndexedDB database contains object stores (analogous to tables). Each object store holds records as key-value pairs where values are structured-cloneable JavaScript objects. Keys can be inline (a property path within the object, specified by keyPath) or out-of-line (supplied separately on each put/add). Auto-incrementing keys are available via the autoIncrement option.

Object stores can have indexes -- secondary lookup structures defined on property paths within stored objects. An index on { name: "email", unique: true } lets you query records by email without scanning the entire store. Indexes can be unique (enforcing a constraint) or non-unique, and can index array-valued properties with multiEntry: true, which creates one index entry per array element.

The Transaction Model

Every read or write operation in IndexedDB must occur within a transaction. You create transactions with a scope (one or more object store names) and a mode (readonly or readwrite; the spec also defines versionchange for schema migrations). Multiple readonly transactions on the same stores can execute concurrently. readwrite transactions on overlapping stores are serialized -- the browser queues them and executes one at a time.

Transactions auto-commit when all requests within them complete and no new requests are placed before the event loop yields. This is the most confusing aspect of IndexedDB for developers accustomed to explicit commit/rollback. If you await a promise between two IndexedDB operations within the same transaction, the transaction may auto-commit during the microtask gap, causing the second operation to fail with TransactionInactiveError. The transaction's lifetime is tied to the synchronous execution context plus any pending request callbacks.

The Async API

IndexedDB predates Promises. The native API uses IDBRequest objects that emit success and error events. Opening a database returns an IDBOpenDBRequest with an additional upgradeneeded event. Schema changes (creating/deleting object stores and indexes) can only happen inside the onupgradeneeded callback, which runs within a special versionchange transaction.

The version number is an integer you provide when calling indexedDB.open(name, version). If the provided version is higher than the stored version, upgradeneeded fires. You cannot downgrade -- requesting a lower version than what exists throws an error. The versionchange transaction blocks all other connections to that database. If another tab has the database open, it receives a versionchange event and must close its connection (or the upgrade blocks until it does).

Libraries like idb wrap this event-based API with Promises, but the underlying transaction auto-commit behavior still applies. You cannot hold a transaction open across an await of a non-IndexedDB promise.

Performance Characteristics

IndexedDB operations are asynchronous and run on a separate thread in most browser implementations. Large writes (thousands of records) are significantly faster when batched in a single readwrite transaction rather than individual transactions. Each transaction carries overhead for the ACID guarantees -- journaling, lock acquisition, and commit flushing.

Cursor-based iteration (openCursor()) processes records one at a time with low memory overhead. The alternative, getAll(), loads all matching records into memory at once -- fast for small result sets, dangerous for large ones. Key range queries via IDBKeyRange (lowerBound, upperBound, bound, only) enable efficient indexed lookups without full scans.

Storage and Eviction

IndexedDB storage is subject to the browser's storage quota, typically a percentage of available disk space. Under storage pressure, browsers may evict IndexedDB data in "best effort" mode. The Storage API (navigator.storage.persist()) can request persistent storage that survives eviction, but the browser may deny the request. In Firefox, persistent storage requires user permission.

Gotchas

  • Transactions auto-commit when the event loop yields -- you cannot await a fetch() or setTimeout mid-transaction. All operations must be queued synchronously or within IDB request callbacks.
  • Schema changes only work inside onupgradeneeded -- you cannot create object stores or indexes during normal application runtime. Plan your schema migrations through version increments.
  • The versionchange transaction blocks all other connections -- if another tab has the database open and does not close on versionchange event, the upgrade hangs. Always handle the versionchange event by closing the connection.
  • getAll() on large stores can exhaust memory -- prefer cursors with continue() for iterating large datasets. Use key ranges to limit the scan scope.
  • Structured clone does not support all types -- Functions, DOM nodes, Error objects, and SharedArrayBuffer cannot be stored. Attempting to store them throws a DataCloneError.