StorageGENERALBROWSER-SPECIFICSPEC-EVOLVING

IndexedDB

Asynchronous, structured, transactional and versioned — a real database in the browser, with a schema you own and a migration path that is where real applications break.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When does client-side data need a transactional, versioned store, and what does owning a schema in the browser actually commit me to?

The user intent

Someone opens the app on a train with no signal and expects their records to be there, their edits to be kept, and nothing to be lost when the tunnel ends and the tab is closed on the way out of the station.

The obvious build

IndexedDB has an unpleasant API, so wrap it in a two-function helper — get(key) and set(key, value) — and use it as a bigger, asynchronous localStorage.

Why it breaks

A key-value wrapper throws away the two properties that justify the API in the first place: indexes, so you can query without loading everything, and transactions, so a multi-record update either fully happens or does not (Transactions and ACID in Databases).

How it breaks in a real browser
  • A key-value wrapper throws away the two properties that justify the API in the first place: indexes, so you can query without loading everything, and transactions, so a multi-record update either fully happens or does not (Transactions and ACID in Databases).
  • Version handling disappears into the wrapper, so the first schema change is discovered in production, in a tab that has been open since before the deploy (Long-Lived Clients and Version Skew).
  • A getAll that loads every record structured-clones the entire result onto the main thread, which is asynchronous right up to the moment it lands and then is not (Structured Clone and Transferables).
  • Transactions auto-close. A transaction that goes a microtask without an active request finishes, so an await on something unrelated in the middle of one produces TransactionInactiveError — a bug that only appears under real timing.
  • Without a migration story, "clear the database and refetch" becomes the fallback, which on a train with no signal means the user's data is simply gone.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A database is opened by name and version. If the stored version is lower than the requested one, the browser fires upgradeneeded and gives you a transaction in which — and only in which — you may create or delete object stores and indexes.
  • Data lives in object stores, which hold structured-cloneable values keyed by an in-line key path or an out-of-line key. Stores can carry indexes over a property, so a lookup by a secondary field does not read every record.
  • Every read and write happens inside a transaction with a mode (readonly, readwrite, or the upgrade transaction) and a scope of object stores. Transactions are the unit of atomicity and of isolation between concurrent operations (Isolation Levels in Databases).
  • The API is request-based: each operation returns an IDBRequest that fires success or error. The awkwardness people complain about is this event-callback surface, layered over an otherwise ordinary database — a wrapper that returns promises removes the complaint without removing the capability.
  • Because the work happens off the main thread, a large read does not block rendering while it runs. Its *result* is structured-cloned into the main thread's heap, and that step is not free (The Real Cost of JavaScript).
  • IndexedDB is available in workers, which makes it the store to use when data is produced or consumed off the main thread (When a Worker Is Actually the Answer).

What this makes the browser do

And which of it is avoidable.

  • Opening a database checks the stored version and, if an upgrade is needed, blocks until every other connection to the old version closes.
  • Writes go through the browser's own durable storage layer, which journals to disk; a readwrite transaction commits atomically or not at all.
  • Index maintenance happens on write: every index over a store is another structure to update, which is the same trade an index costs anywhere else (Why Is This Query Slow? Indexes in Databases).
  • Structured clone runs on both ends of the boundary — once to store the value, once to reconstruct it for the page — and the cost scales with object size and shape.
  • The store participates in the origin's quota pool and is subject to eviction under pressure unless persistence was granted (Storage Security and Durability).

Open, upgrade, transact, request

The lifecycle is four steps and every one of them has a failure that real applications hit. Learning it as a sequence rather than as a set of methods is what makes the error messages legible when they arrive.

The step that deserves the most attention is the second. upgradeneeded is the only place you may change the schema, it runs before your application has rendered anything, and it runs on clients arriving from any earlier version — including ones you no longer have a copy of (Deploying a Frontend).

The IndexedDB lifecycle, and where each step fails
  1. 1
    open(name, version)

    Requests a connection to a named database at a specific version

    fails by VersionError when the on-disk version is *higher* — an old tab meeting a newer schema after a deploy

  2. 2
    upgradeneeded

    Fires with a transaction in which stores and indexes may be created or removed

    fails by Migrating from only the immediately previous version, so a client two versions behind lands in a broken schema

  3. 3
    blocked

    Fires when another connection still holds the old version open

    fails by Being unhandled, so the new tab hangs at startup with no message and no error

  4. 4
    transaction(stores, mode)

    Opens a scoped, atomic unit of work over one or more object stores

    fails by TransactionInactiveError after an unrelated await lets it auto-commit

  5. 5
    request

    Reads or writes within the transaction; fires success or error

    fails by A quota failure aborting the whole transaction — correctly, and the write still did not happen

  6. 6
    complete / abort

    The transaction commits atomically, or rolls back entirely

    fails by Code that treats success on the last request as durability rather than waiting for complete

Two of these six steps exist only because other tabs and other versions exist. That is the real content of this lesson: an IndexedDB integration is a small distributed system whose replicas are browser tabs (Long-Lived Clients and Version Skew).

The migration is the product

A web client never updates atomically. At any moment your users are spread across every build you shipped in the last few months, some in tabs opened weeks ago, and every one of them will eventually run your upgrade. The upgrade therefore has to be written as a sequence, not as a diff against the version you happen to be on.

The pattern below is deliberately plain: numbered steps applied in order, each one idempotent within its own version. A client on version 1 runs steps 2, 3 and 4; a client on version 3 runs only step 4; both end up identical. Anything cleverer than this is a liability, because you cannot test it against a client you no longer control.

An upgrade path that survives version skew
1const DB = 'workspace'
2const VERSION = 4
3
4type Upgrade = (db: IDBDatabase, tx: IDBTransaction) => void
5
6// Each entry brings the schema FROM version n-1 TO version n.
7const UPGRADES: Record<number, Upgrade> = {
8 1: (db) => {
9 const docs = db.createObjectStore('docs', { keyPath: 'id' })
10 docs.createIndex('byUpdatedAt', 'updatedAt')
11 },
12 2: (db) => {
13 db.createObjectStore('outbox', { keyPath: 'opId' })
14 },
15 3: (_db, tx) => {
16 // Backfill, inside the upgrade transaction, with a cursor — not getAll.
17 const store = tx.objectStore('docs')
18 store.openCursor().onsuccess = (e) => {
19 const cur = (e.target as IDBRequest<IDBCursorWithValue>).result
20 if (!cur) return
21 if (cur.value.status === undefined) cur.update({ ...cur.value, status: 'synced' })
22 cur.continue()
23 }
24 },
25 4: (_db, tx) => {
26 tx.objectStore('docs').createIndex('byStatus', 'status')
27 },
28}
29
30function open(): Promise<IDBDatabase> {
31 return new Promise((resolve, reject) => {
32 const req = indexedDB.open(DB, VERSION)
33
34 req.onupgradeneeded = (e) => {
35 const tx = req.transaction!
36 // e.oldVersion is 0 for a brand-new client; run every step in order.
37 for (let v = e.oldVersion + 1; v <= VERSION; v++) UPGRADES[v]?.(req.result, tx)
38 }
39
40 // Another tab is holding the previous version open. Say so.
41 req.onblocked = () => reject(new Error('BLOCKED_BY_OTHER_TAB'))
42
43 req.onsuccess = () => {
44 // A newer tab wants to upgrade: close so it is not blocked by us.
45 req.result.onversionchange = () => req.result.close()
46 resolve(req.result)
47 }
48 req.onerror = () => reject(req.error) // includes VersionError on old clients
49 })
50}

The loop from oldVersion + 1 is the whole idea — it makes the upgrade a replayable sequence rather than a single step from "the previous version". The onversionchange handler is the other half: without it, an old tab blocks every new one indefinitely.

The failures that only happen to users

Every row here is invisible in development, because development has one tab, one version, a fast disk and plenty of free space. They are the reason an IndexedDB integration needs error reporting from real sessions before you can claim it works (Frontend Error Tracking).

Notice how many responses are a product decision rather than a code fix. "Tell the user to close other tabs" and "offer to re-download rather than silently starting empty" are the honest answers, and they need design, not just a catch.

IndexedDB in the field
TriggerSymptomCauseResponse
A deploy raises the database versionTabs opened before the deploy fail to open the database at allVersionError: the on-disk version is higher than the version that old code requestsDetect it, and prompt for a reload rather than rendering a broken shell (Long-Lived Clients and Version Skew).
A second tab is open during an upgradeThe new tab hangs at startup with no error and no spinner stateThe upgrade is blocked by the other connection and blocked was never handledHandle blocked, and add onversionchange in every tab so old connections close themselves.
An await fetch inside a transactionTransactionInactiveError on a slow connection onlyThe transaction auto-committed while nothing was pendingDo network work outside the transaction; open a new one for the result (The Life of a Fetch).
A migration that rewrites every recordLong-standing users see a blank screen at startup; new users do notThe upgrade transaction is doing work proportional to the store, before first renderBackfill lazily on read where possible, and show an announced progress state when it cannot be (Loading, Error, Empty — The States You Did Not Render).
Disk pressure on the deviceOffline data disappears; the app looks freshly installedEviction reclaimed the origin's storageRequest persistence, and make the empty state offer to re-sync rather than assume a new user (Offline UX).
A getAll over a grown storeA visible stall right after a read that was supposed to be asynchronousStructured clone delivers the entire result onto the main thread in one taskPage with a cursor or an index range and render what is visible (List Virtualization).

How to build it

Most important first.

  • Use a thin promise wrapper over the raw API, not an abstraction that hides stores, indexes and transactions. The awkwardness is worth removing; the model is not.
  • Design the object stores and indexes from the queries the UI actually makes. A store you always read in full does not need an index; one you filter by date or status does (Should I Add an Index? in Databases).
  • Write the upgrade path as a sequence of numbered steps that run in order, so a client on version 1 and a client on version 3 both arrive at version 4 correctly. This is the single highest-value piece of code in an IndexedDB integration.
  • Never do unrelated await work inside a transaction. Open it, do the requests, let it commit; if you need a network call, do it before or after (The Life of a Fetch).
  • Read what you will show, not everything you have. Use a cursor or a key range for lists rather than getAll over the whole store (List Virtualization).
  • Handle the blocked event on upgrade explicitly: another tab is holding the old version open, and the honest response is to tell the user to close other tabs rather than to hang (Auth Across Tabs).
  • Request persistence for data the user would consider theirs, and design the recovery path anyway, because persistence can be declined (The Offline Mutation Queue).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • This store is usually what makes an offline or resumable experience possible, and resumability is an accessibility property: someone who took twenty minutes to complete a form with switch access cannot simply retype it (Offline UX).
  • Losing that state silently is the failure to avoid. If a migration discards data or the database was evicted, say so in a live region and offer a path forward, rather than rendering an empty state that looks like a fresh install (Live Regions and Announcement).
  • Migrations and large reads happen at startup. If they take time, the loading state must be announced and focusable rather than a spinner that assistive technology cannot see (Loading, Error, Empty — The States You Did Not Render).
  • Because the work is asynchronous, IndexedDB does not block the accessibility tree the way a synchronous read does — but structured-cloning a huge result back onto the main thread does, so read only what you will render (The Accessibility Tree).

What can go wrong

Failure modes
  • VersionError — a tab running old code opens the database with a lower version than what is on disk, and the open fails outright. This is the standard consequence of deploying a schema change to long-lived clients.
  • A blocked upgrade: the new tab waits for the old tab to close its connection, and if the old tab never does, the new one appears to hang on startup with no error at all.
  • TransactionInactiveError from an await that let the transaction auto-commit. It is timing-dependent, so it passes locally and fails on a slower device.
  • A migration that reads and rewrites every record inside the upgrade transaction, blocking startup on a large store — and with no progress to show, because the app has not rendered yet.
  • Quota exceeded mid-transaction: the transaction aborts, which is the correct behaviour and still means the write did not happen.
  • Eviction under disk pressure, which removes the database entirely. Code that reads "no records" as "new user" then overwrites the server with an empty state (Optimistic UI).
  • The mitigation failing: a wrapper that retries a failed transaction without checking whether it partially applied can duplicate records rather than repair them (Idempotency in API Design).
What can arrive out of order
  • Two tabs writing overlapping records concurrently: transactions serialise per store, so neither corrupts the database, and the later transaction still overwrites the earlier one's values unless you version records yourself (Optimistic Concurrency: Versions and If-Match in API Design).
  • An upgrade racing an open connection in another tab: the upgrade is blocked until that connection closes, and the old tab may keep operating on the old schema until it does.
  • A service worker and a page writing the same store at once — both are legitimate clients of the same database, and neither knows about the other (The Service Worker Lifecycle).
  • A replayed offline mutation racing a fresh server response, so a stale local edit lands after the newer server state (Out-of-Order Responses).
Security
  • The database is scoped to the origin and readable by every script on it. Structure and size give no protection: any injected script can enumerate stores and read every record (Cross-Site Scripting).
  • There is no encryption at rest here beyond whatever the operating system provides for the browser profile, so offline copies of sensitive records are copies you have chosen to leave on the device (Sensitive Data Classification in Security Engineering).
  • The browser enforces the origin boundary and, increasingly, partitions storage by top-level site, so a third-party frame may not see the database it saw before (Storage Security and Durability).
  • An offline mutation queue is a set of requests that will be replayed later with whatever credentials exist at replay time — which makes it a place where authorization must be re-checked server-side, not assumed (What the Frontend Is Responsible For in Auth).
Misreads
  • "IndexedDB is slow." It is asynchronous. The database work is off the main thread; what you can make slow is reading more than you need and cloning it back.
  • "It is a key-value store with a bad API." It has indexes and transactions. Using it as a key-value store is a choice, and usually the wrong one at the sizes that justify it.
  • "Async means I cannot block the page." A getAll over a hundred thousand records delivers its result onto the main thread in one piece.
  • "The upgrade runs once, on my machine, so it works." It runs on every client, from whatever version they were on, possibly with another tab holding the old version open.
  • "A transaction stays open until I close it." It auto-commits once it has no pending requests, which is why an unrelated await inside one is a bug.
  • "Data here is safe." It is quota-managed and evictable, like everything else in the browser (Storage Security and Durability).

Measuring it, and what changes in the field

How you would see this
  • The Application panel shows each database with its version, its object stores, its indexes and its records — the fastest way to confirm which version a given profile is actually on (A Mental Model of the Devtools).
  • A storage-estimate call reports usage and quota for the origin, which is the number to watch as an offline cache grows.
  • The Performance panel shows the structured-clone and deserialization cost of a large read as main-thread time, distinct from the database work that happened off it (Debugging the Network).
  • In the field, report VersionError, blocked and quota failures as named error classes; version skew is invisible in any environment where everyone reloads (Release Health).
Slow device, slow network, large data, old tab
  • On a slow device the database work is not the bottleneck — the structured clone and the rendering of what you read are (What a Component Costs to Render).
  • With a large store, a migration that touches every record becomes a startup stall proportional to the data, on the exact users who have used the app the most.
  • With several tabs open, upgrades block and version skew is normal rather than exceptional; the deploy story has to account for it.
  • On a device short of disk, quota shrinks and transactions abort at sizes that previously committed.
  • Offline, this store is the application: everything the UI can show is what is in it (Caching Strategies).
What this costs
  • Real capability for real ownership. You get indexes, transactions and size; you take on a schema, a migration path and a version-skew problem that Web Storage never gave you.
  • Indexes make reads cheaper and writes more expensive, exactly as they do server-side.
  • A promise wrapper makes the API pleasant and hides the transaction lifetime, which is precisely the thing that causes TransactionInactiveError — a good wrapper documents it rather than pretending it is gone.
  • Storing more locally improves the offline experience and increases both the eviction blast radius and the amount of data sitting on a device you do not control.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe object-store model, the version-and-upgrade lifecycle, transaction scoping and auto-commit behaviour are specified and consistent across Blink, Gecko and WebKit; code written against the model works everywhere.
  • BROWSER-SPECIFICQuota, eviction order under pressure and whether a persistence request is granted are implementation policy, and Safari has historically applied both a smaller effective budget and time-based deletion of script-written storage that Chrome and Firefox do not.
  • SPEC-EVOLVINGStorage buckets, persistence semantics and partitioning of storage by top-level site are still moving; treat durability guarantees as a request you make and verify rather than a property you can rely on across browser versions.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — an offline store plus a server is replication with a partition that lasts as long as the train tunnel; the conflict-resolution rules for that belong to that domain.
  • Testing & Reliability Engineering — the upgrade path is the piece most worth testing from every historical version, and doing it needs fixtures for schemas you have already deleted.
OS & Networkingfile-systems