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",
});| Option | Default | Effect |
|---|---|---|
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. |
uniqueKeyCacheBytes | 8 MiB | Maximum modeled memory for the optional complete key-membership cache. 0 disables it; point probes remain correct. |
indexedDB | the global | An 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 resource | Ceiling |
|---|---|
| Live reader/backup leases | 4,096 |
| Active transactions | 4,096 |
| Globally staged transaction blocks / segments | 1,048,576 / 1,048,576 |
| Globally staged transaction payload bytes | 512 MiB |
| Catalog table/view records, including a pending table | 4,096 |
| Catalog table/view metadata bytes | 64 MiB |
| Manifest summaries / metadata bytes | 65,536 / 64 MiB |
| Segment records / metadata bytes | 1,048,576 / 512 MiB |
| Active temp owners | 1,024 |
| Temp runs per owner / total | 1,024 / 65,536 |
| Temp pages per owner / total | 16,384 / 262,144 |
| Temp bytes per owner / total | 512 MiB / 1 GiB |
| Active compaction jobs / GC jobs | 1,024 / 1 |
| Active UNIQUE / full-text / secondary-index builds | 1,024 / 128 / 128 |
| Staged accelerator bytes / entries across all builds | 1 GiB / 16,777,216 |
| Terminal transaction / compaction / completed-GC records | 65,536 / 4,096 / 1,024 |
| Oldest pinned-manifest lag | 4,096 versions |
| Retired blocks / bytes held by live pins | 65,536 / 512 MiB |
| Total retained obsolete block history | 1 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.