Storage

IndexedDB

The durable adapter — options, durability, quota, and what it stores.

import { IndexedDbBlockStore } from "@minnowdb/core/storage/indexeddb";

const store = await IndexedDbBlockStore.open({
  name: "shop",
  durability: "strict",
});
OptionDefaultEffect
name—The IndexedDB database name. Two stores with the same name are the same database.
durability"strict""relaxed" allows the browser to defer the final disk flush for more write throughput.
uniqueKeyCacheBytes8 MiBMaximum modeled memory for the optional complete key-membership cache. 0 disables it; point probes remain correct.
indexedDBthe globalAn IDBFactory to use instead, for tests.

Durability

strict, the default, requests a disk flush for every commit. It costs write throughput, but an acknowledged commit is not intentionally left only in an operating-system cache.

relaxed keeps the same atomic commit boundaries while allowing the browser to batch final disk flushes. A machine or storage-device power loss may lose an acknowledged suffix of commits, so use it only when every acknowledged write can be reconstructed, replayed, or recovered from another durable source and that loss window is acceptable. Use strict whenever losing an acknowledged write is unacceptable.

Quota

Browsers give an origin a share of free disk, not a fixed number, and evict from origins the user has not visited when space runs low.

import { ensureOriginPersistence } from "@minnowdb/core/storage/persistence";

const { quota, usage } = await navigator.storage.estimate();
await ensureOriginPersistence("required");

Persistence may be granted silently, denied, or require a user gesture depending on the browser. "required" fails before the application opens its store when the browser cannot protect the origin from automatic quota eviction. Use "request" to receive a status instead, or "best-effort" to check without prompting. No browser API prevents the user from deliberately clearing site data. When acknowledged writes must survive those events too, export or synchronize an independent durable copy.

getLogicalStorageBytes() reports what this database occupies, which is the number to show a user and the one to watch before a bulk load:

await store.getLogicalStorageBytes();

What it creates

One IndexedDB database with eleven object stores: blocks, manifests, segments, transactions, transactionJournal, catalog, leases, temp, gc, statistics, and snapshotHeaders. Block payloads are stored as Uint8Array values keyed by block id. Manifest membership is interval provenance beside each block, so snapshot export can cursor the captured version without constructing a descriptor array; integrity checks cross-check that provenance against the payload store.

A transaction record in transactions holds the record's fields plus three counts: journaled blocks, journaled segments, and journal chunks. The block and segment ids themselves live in transactionJournal as chunks keyed [transactionId, chunkIndex], each holding at most 1,024 ids in journal order, and every chunk but the last is full. Staging reads only the last chunk and writes only the ids it adds, so a transaction can journal as many artifacts as the global staging ceilings allow without each step growing with the journal. Reads reassemble the contract's pendingBlockIds and pendingSegmentIds from the chunks and refuse a record whose counts, chunk count, or chunk contents disagree. Commit, abort, and savepoint rollback still validate the whole journal; a record's chunks are deleted with the record.

A resumable snapshot import keeps one scalar identity/progress marker in catalog until the final transaction publishes the manifest pointer and deletes the live marker atomically. Its header contains only per-kind counts and byte totals. Canonical metadata is carried as one semantic item per frame in snapshotHeaders; block frames carry one raw block. Metadata frames are at most 4 MiB, one append accepts at most 64 frames and 16 MiB of metadata, and block payloads retain the normal block-size ceiling — no browser must clone database-sized metadata as one IndexedDB value. While the marker exists, ordinary catalog writes through the adapter abort; final publication also rechecks both the marker identity and the still-empty current-manifest pointer before it publishes anything.

An application can inspect that state with inspectInterruptedImport(). Retrying the same snapshot continues it; abortInterruptedImport(identity) atomically verifies the marker and clears the still-unpublished target, including staged payloads, metadata frames, and any unrelated low-level writes made while the marker blocked normal engine use. A mismatched identity or a database that has since published refuses the abort without deleting anything.

Once an import owner's bounded lease expires, a different snapshot may claim the still-empty target. That takeover first clears every staged block, table, segment, transaction, provenance record, metadata frame, and resource ledger in the same transaction that installs the new marker. If any part of that transaction fails, the old import remains intact and resumable.

The importer writes blocks, tables, segments, transactions, UNIQUE generations, and posting generations in bounded frame batches and keeps durable cursors in the marker. Finish validates the staged stores with sequential cursors and then publishes the manifest summary, current pointer, catalog epoch, and completed replay identity atomically. An exact retry after a lost final response compares every payload byte and finishes as a no-op; a later commit removes the bounded completed identity record.

Only one live snapshot export is admitted. Its backup lease is indexed by expiry and protects the captured manifest; an expired session is reclaimed before another begins. This bounds both durable pins and the adapter's descriptor cache even when a caller abandons an iterator.

Each manifest is a fixed-size summary. Block membership is stored once as ordered interval provenance — the version that added the block and, when retired, the version that removed it. Publishing therefore writes work proportional to the changed blocks, never a periodic copy of all live block ids. Version membership checks are point reads, snapshot and maintenance scans are cursor-paged, and deleting an old manifest summary does not erase the provenance GC needs to finish reclaiming its retired blocks.

Cleanup removes the oldest pruned summaries in bounded transactions, so closing between pages leaves a valid predecessor chain. Earlier releases could leave an unfinished deletion range; its durable cleanup marker identifies that exact range for integrity checks and resumption. An unrelated gap remains a corruption finding.

UNIQUE membership uses immutable lexical generations plus at most 16 ordered commit tails. Every base or tail part is capped at 2,048 tokens and a conservative 2 MiB retained-size model. The seventeenth tail is folded by a bounded k-way merge into a new generation; the old generation and tails disappear in the same commit. Builds require globally strict lexical input, append the same bounded parts under an expiring owner, and publish by switching the namespace generation pointer in the same catalog transaction.

Point and bulk probes bound each cursor to its selected base or tail source, seek only the predecessor parts for their requested tokens, and retain one decoded part at a time. Small and bulk-loaded tables can still use the optional complete membership cache; it drops itself at uniqueKeyCacheBytes, after which persistent probes remain authoritative. The limit also works in a worker store descriptor:

{ store: { kind: "indexeddb", name: "shop", uniqueKeyCacheBytes: 4 * 1024 * 1024 } }

Hard growth ceilings

IndexedDB quota failures otherwise arrive late and vary by browser. The adapter therefore applies fixed admission ceilings before the transaction that would cross them. A refusal throws StorageResourceLimitError and leaves the journal, catalog, manifest pointer, and payload stores unchanged. Expired owners and bounded terminal-history pages are reclaimed before admission where that is safe.

Durable resourceCeiling
Live reader/backup leases4,096
Active transactions4,096
Globally staged transaction blocks / segments1,048,576 / 1,048,576
Globally staged transaction payload bytes512 MiB
Catalog table/view records, including a pending table4,096
Catalog table/view metadata bytes64 MiB
Manifest summaries / metadata bytes65,536 / 64 MiB
Segment records / metadata bytes1,048,576 / 512 MiB
Active temp owners1,024
Temp runs per owner / total1,024 / 65,536
Temp pages per owner / total16,384 / 262,144
Temp bytes per owner / total512 MiB / 1 GiB
Active compaction jobs / GC jobs1,024 / 1
Active UNIQUE / full-text / secondary-index builds1,024 / 128 / 128
Staged accelerator bytes / entries across all builds1 GiB / 16,777,216
Terminal transaction / compaction / completed-GC records65,536 / 4,096 / 1,024
Oldest pinned-manifest lag4,096 versions
Retired blocks / bytes held by live pins65,536 / 512 MiB
Total retained obsolete block history1 GiB

These are corruption and unbounded-growth fuses, not suggested operating targets. Normal maintenance should keep the counts far below them. When a pin or backlog reaches a ceiling, let the owning read/export finish, run maintenance, or reduce write pressure before retrying.

The byte columns are exact accounting units, not estimates of IndexedDB's browser-specific physical allocation. Catalog and segment ledgers charge the canonical UTF-8 record-wire bytes. Each manifest is charged as the canonical summary it will retain after pruning: an unpruned summary reserves the exact 24-byte UTC prunedAt tombstone spelling up front. Pruning can therefore replace a summary at the 64 MiB ceiling without needing one more byte to make the record collectable. The checked ledgers are updated in the same IndexedDB transaction as their records; full integrity mode recomputes them from the underlying stores and reports a mismatch as corruption.

Collection cost

Collection retains corruption and snapshot-pin checks inside the same atomic transaction as reclamation. On browsers, metadata validation reads at most 128 records per batch and pipelines block-presence checks. A step builds its pinned-version set once and keeps at most 4,096 cached ownership roots; overflow falls back to exact probes. That cache lasts only for the current IndexedDB transaction, so another tab cannot make a cached root decision stale.

The candidate item limit bounds reclamation, not every validation read. Validation still depends on retained history, and readers and writes can wait behind a collection transaction. Measure sustained writes with maintenance enabled on the devices you support; the memory adapter's throughput does not describe strict IndexedDB latency.

Table listing reads only table metadata keys. Full snapshot pin and compaction-source checks also use bounded read batches, so each retained block does not require a separate cursor turn.

Multiple tabs

Several tabs may open the same database at once. Engine snapshots keep reads consistent while other connections publish. Physical IndexedDB operations can queue behind overlapping read/write transactions, including collection. Every writer — scopes, batch and SQL writes, BEGIN transactions, DDL, and maintenance publications — takes its turn through the Web Lock minnowdb-write:minnowdb-live:indexeddb:<name> before reading the state it depends on, so tabs never conflict with each other; see writer turns. Storage compare-and-swap remains the correctness boundary for a writer that does not take a turn. A tab that stops inside its turn holds the others until it finishes or the browser releases its lock; the wait is reported after ten seconds and never bypassed, and closing a waiting engine or client cancels its wait at once.

When a newer build requests an IndexedDB schema version, every Minnow connection closes itself on the browser's versionchange event so it cannot strand the open. If a non-cooperating connection blocks the open, open() rejects with IndexedDbSchemaUpgradeBlockedError instead of waiting forever. The error carries the database name and old/requested versions; close the older tab and retry. A request that already reported this error is marked abandoned, so it cannot wake later and silently upgrade or rebuild the database after its caller has moved on.

Transaction completion is observed before requests are submitted. An abort remains observable even if quota or request-failure processing resumes after the abort event.

A connection that stops answering

IndexedDB requests cannot be cancelled and report no progress, so the adapter keeps one deadline for the whole connection: if thirty seconds pass with no storage event at all while it has work outstanding, every waiting call fails with StorageUnresponsiveError and the store refuses new work rather than queueing behind something that never moves. Any event resets the deadline, so a transaction waiting behind a genuinely long one is never mistaken for a wedge. Progress advances the deadline without replacing a native timer for every request. The timer checks the latest deadline when it wakes; a newly queued request does not count as progress. unresponsiveAfterMs on IndexedDbBlockStore.open changes the deadline.

A wedge like that has one known cause: a worker killed with a write in flight. WebKit keeps the dead worker's connection, and its unfinished transaction, registered until the document that created the worker goes away, and until then every connection to that database blocks — new ones in other tabs included. No adapter can undo that, so it bounds the wait and names the remedy: reload the page. See Errors for what a caller should do with it.

Schema compatibility and recovery

The current IndexedDB schema is 4. Schema 1 kept each transaction's journal ids on the record itself; opening a schema-1 database moves them into transactionJournal chunks and rewrites the records with their counts. Schema 3 changes no stored record: it admits compaction jobs whose merge plan is recomputed from its sources rather than stored cell by cell, which a schema-2 build cannot read. Schema 4 rewrites nothing either: one commit may store an indexed column's changes as several part records, with a small directory of their term ranges, so a commit's index changes have no size limit and a lookup reads only the parts its terms fall in. A schema-3 build cannot read those parts. Opening a schema-2 or schema-3 database only raises the version, and the older build then refuses it. There are no pre-contract schemas or compatibility branches. Every schema change adds one ordered migration for every integer version between the database and the current build. All of those migrations run inside the browser's single versionchange transaction: either the complete chain and its data changes commit, or the old database remains byte-for-byte logical state. A database laid out in every stable schema is opened, migrated, and checked in CI, and the migration registry refuses to load if a schema bump omitted or reordered a step.

A build older than the database throws StorageFormatVersionError and leaves every store, index, and record untouched; it never downgrades or recreates the database. Malformed current-schema metadata is StorageCorruptionError, also without automatic deletion. Use snapshots or remote synchronization for recovery from genuine corruption or browser storage loss; no local adapter can reconstruct the only copy of bytes that are gone.

One caveat worth knowing: a browser may throttle or suspend a background tab's IndexedDB activity. A long compaction in a hidden tab can simply stop making progress until it is foregrounded, which is why maintenance is stepped and resumable rather than one long operation.

On this page