Transactions and snapshots
Making several writes atomic, holding one version across several reads, and what happens when two writers collide.
Atomic writes
You can group writes with SQL BEGIN / COMMIT, use a callback with the engine APIs, or use an
adapter's transaction API.
Batch write scopes
A write scope stages multiple mutations across multiple tables and publishes them as one commit:
const { result, version } = await db.write(async (tx) => {
await tx.updateBatch("stock", { keys: [sku], changes: { on_hand: [nextOnHand] } });
await tx.insertBatch("shipments", [{ sku, qty, shipped_at: new Date() }]);
return sku;
});The callback can mix batch calls with parameterized SQL through tx.execute(...). Kysely maps its
transaction callback to Minnow's SQL transaction statements; see the
Kysely adapter.
A database has one writer at a time. A scope takes its turn before its callback runs — in order
with every other write on this engine, on other engines in this context, and in other tabs and
workers over the same database — and keeps it until its commit or rollback has a known outcome.
Concurrent write() calls therefore run one after another, never against each other: no scope
spends a retry on a sibling, no callback runs twice, and a failed scope rolls back and hands the
turn to the next writer. Reads through query() do not take turns and never wait behind a
callback; they keep snapshot isolation and see their own scope's staged rows.
Inside the callback, use tx for every write. Another db.write(), a standalone batch write, or
an autocommit SQL mutation awaited from inside the callback waits for the callback to return, so
it waits on itself; the engine reports such a wait through onBackgroundError after ten seconds
and keeps waiting rather than guessing. Keep network requests outside the callback: fetch first,
then open the scope with the data in hand. write(callback, { signal }) cancels a scope still
waiting for its turn, which then never runs its callback, or aborts one already inside it.
tx.upsertBatch accepts the same conflictWhere guard as the autocommit form (see
Bulk writes), so a guarded upsert composes atomically with the rest of
the scope — for example, replacing an offline-created row with its server-confirmed twin only when
nothing has since edited it locally, in the same commit as the delete that removes the local
placeholder:
await db.write(async (tx) => {
await tx.deleteBatch("orders", { keys: [offlineId] });
return tx.upsertBatch("orders", [serverRow], {
conflictWhere: { column: "_synced", operator: "=", value: true },
});
});Everything inside becomes visible at once, including to other tabs: nobody observes the stock decremented without the shipment. If the callback throws, nothing is published.
Overlapping session calls are ordered: mutations wait for earlier calls, and adjacent reads may run together against the same staged state. Commit joins admitted calls before publishing. Await each call to handle its error. One engine admits at most 256 write/snapshot scopes; within a write scope, at most 64 mutations and 256 reads may be pending.
Never split a write to suit the engine. Hand it the whole batch: a statement stages about one
block per column per 65,536 rows, so a million-row insert of a 100-column table is a few
thousand blocks, and the engine encodes and persists them with backpressure instead of holding
every encoded block in memory. Chunking only multiplies commits, and each commit is a durable
flush, a manifest version, a live-query invalidation, and one more small segment for every
later read to consult until compaction folds it; on the OPFS store, fifty thousand guarded
upserts land four to seven times slower as 141-row scopes than as one batch. Inside a scope,
statements coalesce too. Each insert, update, delete, and upsert still validates, fills
defaults, reserves identity values, registers its unique keys, and proves its foreign keys on
the spot — a second insert of a key the scope holds, or an update of a key it deleted, fails that
statement and leaves the scope usable — but its effect joins the table's write set, a per-key memtable in which the
statements that touch one key fold into a single net effect: an insert then an update is one
inserted row, an update then a delete is one delete, a delete then an insert replaces the
committed row in place. The set becomes at most one insert, one upsert, one delete, and one
update segment per distinct set of changed columns when something needs the segments: a read of
the table, a savepoint, the commit, or a block's worth of rows waiting. So a loop of single-row
statements stages what one batch would, and a statement of 4,096 rows or more is encoded on the
spot, after whatever the set held for its table. A keyed UPDATE … SET column = constant WHERE key = ?
or DELETE … WHERE key = ? (also IN (…)) joins the set without reading the table; an
assignment that is an expression over the row, a RETURNING clause, or extra FROM sources
reads first, which encodes what waits for that table. The checks the engine runs for itself —
which keys a guarded upsert will update, the pre-image behind a CHECK constraint or a unique
index — answer from the write set and the committed snapshot, within a 64 MiB budget per scope,
so they never force the encoding either. Tables with triggers stage per statement, because a
trigger sees each statement's rows.
The bounds that remain are far away. Small successive stages share one bounded local batch and
publish in one atomic storage call; exceeding it moves artifacts into the recovery journal. One
adapter call carries at most 64 blocks, 64 segment records, and 64 MiB of payload, and the
engine splits larger stages into as many calls as it takes. A transaction's journal itself has
no length limit: staging costs what it stages, not what the journal already holds, in every
adapter. What bounds an unpublished transaction is the store's aggregate quota — 512 MiB of
staged artifact bytes across all active transactions, and 1,048,576 staged blocks or segments —
and its lease, which a crashed tab's transaction loses. A transaction's commit also carries its
key and index changes — one key per row of a keyed table plus one entry per row for each
secondary or full-text index — and those have no fixed limit: each store writes them in bounded
pieces, and a large commit prepares them a slice at a time so that it never holds the thread for
long. On OPFS a single commit's log frame must still fit the log, about 960 MiB, which is
millions of rows, and the database's metadata — every keyed table's keys among it — must fit one
256 MiB checkpoint. A database also admits at most 64 pending
write calls, and a buffered writer at most 64 unawaited add() calls, so a producer must await
writes when it reaches backpressure.
Reads inside a scope see everything the scope has staged, and nothing folds those segments
before the commit. A keyed read — SELECT … WHERE key = ? — replays that key through the
staged segments and stays cheap however many there are: under 1 ms with a thousand of them on
the memory store. A read that scans the table walks every staged segment, so a long scope that
scans in every iteration pays a little more per scan as it goes; on the memory store, 1,500
read-then-upsert cycles on a 60-column table average 7 ms each. Write loops without reads pay
neither — their statements fold into the write set — and the checks the engine makes for itself
answer from the write set too.
The scope durably pins its pre-scope version from the start. While its first bounded write batch is still process-local, a renewable reader lease owns that pin. If an overflowing stage persists a recovery journal, ownership passes without a gap to the renewable transaction record. Compaction and garbage collection therefore cannot remove the base snapshot while user code is idle between statements, and a crashed scope leaves only deadline-bounded metadata behind.
A scope ends with its callback, so a thrown error or forgotten branch cannot leave it open. SQL transactions are available too, with a 30-second idle rollback to protect against a caller that opens one and disappears:
await db.execute("BEGIN");
try {
await db.execute("UPDATE stock SET on_hand = on_hand - $1 WHERE sku = $2", [qty, sku]);
await db.execute("INSERT INTO shipments (sku, qty) VALUES ($1, $2)", [sku, qty]);
await db.execute("COMMIT");
} catch (error) {
await db.execute("ROLLBACK");
throw error;
}An idle rollback leaves the connection in a failed state: later statements reject with
TransactionExpiredError until an explicit ROLLBACK acknowledges it or BEGIN starts a new
transaction. A delayed statement cannot silently become an autocommit. Calls to execute() on
one connection run in invocation order, including BEGIN, mutations, and COMMIT.
The deadline is transactionIdleTimeoutMs, and it measures the time since the connection's last
statement, not the age of the transaction. A statement still running keeps the transaction open
however long it takes, and each statement restarts the clock, so a transaction a caller keeps
using stays open. Three things end one: its own COMMIT or ROLLBACK, that deadline, and closing
the database. Another connection is not one of them — its crash, reopen, recovery, compaction, or
garbage collection never rolls your transaction back, because a sweep reclaims only work whose own
lease has expired. What another connection can do is win the commit race below.
Reads inside the transaction see its earlier writes, including scalar subqueries in INSERT VALUES and trigger INSERT bodies. A statement that violates a unique key or a UNIQUE index fails on that statement, as it does in PostgreSQL, whether the conflicting row was committed before the transaction or staged earlier in it — an insert of a held key, or an update that moves a row onto a term another row holds; a swap within one statement is fine. The transaction stays open, so the caller decides whether to roll back or continue. Schema changes are refused because they are saved outside the transaction. Savepoints checkpoint staged blocks, mutation deltas, unique-index membership, and trigger effects:
BEGIN;
SAVEPOINT before_items;
INSERT INTO order_items (order_id, line_no, sku) VALUES (42, 1, 'A-1');
ROLLBACK TO SAVEPOINT before_items;
RELEASE SAVEPOINT before_items;
COMMIT;ROLLBACK TO keeps the named marker and removes markers created after it. RELEASE removes the
named marker and its nested markers. Reusing a name shadows the older marker. A second BEGIN
does not nest; use a savepoint instead. A transaction may hold at most 64 savepoints, whose
retained rollback state may total at most 8 MiB. Release markers that are no longer needed.
A failed scope stays failed
A statement that fails after staging part of its work cannot be undone one statement at a time. Even if your code catches the error and carries on, the scope refuses to commit rather than saving only part of the change.
A statement that fails validation before registering anything — updating a key that does not exist, say — leaves the scope clean and usable.
Recovering an interrupted checkout
A lost worker reply does not tell the application whether a commit happened. Use a stable
application ID, such as the retail schema's order_id, to reconcile the outcome:
- Persist a pending checkout intent with its ID and immutable purchase details before submitting the checkout. Keeping the ID only in memory cannot recover it after a browser restart.
- In one write scope, look up that ID. If the order already exists, verify that its details match and return the recorded result. Refuse reuse of the same ID for a different purchase.
- Otherwise, write the order, its
order_items, inventory changes, and the intent's completed state in the same scope. Unique constraints on operation IDs protect concurrent retries. - On a confirmed write conflict, retry the database-only scope from a fresh snapshot with the
same ID. Bound these retries. On
DatabaseWorkerOutcomeUnknownError, reopen the connection and reconcile the durable intent and order before deciding whether to submit again.
Two tabs can both find an order absent; one can then lose the commit race. The retry must repeat its lookup, not blindly replay the inserts. Keep external payment calls outside a retried database callback, and reconcile them separately using the payment system's own durable operation ID. Neither an RPC request ID nor a timeout establishes that a payment or database commit failed.
Stable reads
Every single query already executes against one version. A snapshot scope extends that to several:
const report = await db.snapshot(async (session) => {
const orders = await session.query("SELECT COUNT(*) AS n FROM orders");
const items = await session.query("SELECT COUNT(*) AS n FROM order_items");
return { orders: orders.rows[0].n, items: items.rows[0].n };
});Both queries see the same version, so the counts are consistent with each other however many commits land while the scope is open. A scope opened before the first commit stays empty even when another connection publishes that first version.
The scope holds a lease on that version so background collection cannot reclaim it underneath, and renews it while the callback is open, and releases it when the callback returns. If the browser suspends execution beyond the lease deadline and collection expires it, the resumed scope fails explicitly; it does not switch to a newer version. Keep scopes short: a long-lived scope prevents reclamation of the history it needs. Closing the database cancels idle scopes and joins storage cleanup without waiting for user code to resume.
Freshness
Outside a snapshot scope, every query observes the latest committed state — including commits from another tab — at the query's snapshot boundary. A concurrent commit may land after that boundary.
Cross-tab read consistency does not depend on
BroadcastChannel or Web Locks. A reader checks the current version in the same storage
transaction it reads through.
Conflicts
Readers retain a stable version while writers publish a new one. Storage operations can still queue behind each other: snapshot isolation is not a promise of zero read/write latency.
Every path that publishes takes one turn as the database's writer before it reads the state it
depends on: write() scopes, insertBatch and its siblings, SQL mutations, BEGIN … COMMIT,
migrate() and every DDL statement, compaction and index publications, and snapshot imports. The
turn is a local queue per store, shared by every engine in the same context that opened the same
store name, and — where the browser offers Web Locks — the lock minnowdb-write:<store> across
tabs and workers. Turns are taken in arrival order; a BEGIN holds its turn until COMMIT,
ROLLBACK, the idle rollback, or close; a migration holds one for all of its catalog steps. There
is nothing to configure and no application-side queue or retry loop to write: writers issued from
several tabs at once land in some serial order with no conflict between them.
db.writeCoordination (client.writeCoordination() through a worker) reports how far the
turn reaches: cross-context with a store identity and Web Locks, context with only the
identity (Node, or a browser without Web Locks — every engine in the context over that name
still takes turns), and instance for a custom store with no liveQueryChannelName, where only
engines sharing the same store object do.
Storage compare-and-swap stays underneath as the defensive check. Writers that do not take turns
— an older Minnow build in another tab, a custom store without an identity in another context,
a crashed tab's commit landing as the lock passes on — still cannot interleave with a turn: a
plain one-statement write restarts from a fresh snapshot, up to maxCommitRetries (eight by
default), so predicates, constraints, foreign-key proofs, and trigger bodies are recomputed, and
exhaustion returns WriteConflictError without acknowledging anything.
import { WriteConflictError } from "@minnowdb/core/storage/contracts";
try {
await db.write(async (tx) => {
/* … */
});
} catch (error) {
if (error instanceof WriteConflictError) {
// Another writer won repeatedly. Re-read and decide what the write should now be.
}
}An explicit db.write() scope or SQL transaction never replays application code. Among
writers that take turns it never needs to: nothing commits between a scope's snapshot and its
commit. Only a writer that did not take a turn can still land in that window, and then the
scope surfaces WriteConflictError, nothing from it publishes, and the caller may rerun the
complete operation after deciding that its side effects are safe to repeat. One such data commit
is enough, even to a table the scope never touched. A SQL transaction that loses that way fails
at COMMIT, and the connection is then out of its transaction with nothing to acknowledge: the
next statement is an ordinary autocommit, unlike the failed state an idle rollback leaves. A
transaction that only reads publishes nothing and cannot lose. Compaction and garbage collection
publish versions of their own but rewrite no rows; a fold that lands without a turn is rebased
over automatically, within maxCommitRetries.
A holder that stops — a tab paused by the browser inside a callback, or a callback awaiting a
network call — holds every other writer to the database. The wait is reported through
onBackgroundError (a WriteAdmissionStalledError with the context write admission) after ten
seconds without a change of holder and continues until the holder finishes, the browser releases
the holder's lock when its tab is unloaded or discarded, or the waiter is cancelled: closing an
engine or a client rejects everything it still has waiting without waiting for the holder. The
engine never takes a turn away from a live holder, because neither storage adapter can fence a
resumed writer at the commit boundary, and a lock a timeout can defeat is not a lock. An idle SQL
transaction is bounded separately by transactionIdleTimeoutMs.
The watchdog reports only an observed holder. An empty or unavailable Web Locks snapshot does
not establish a stalled writer; writers behind the local queue inspect its holder instead of
requesting a remote lock snapshot.
Schema changes take turns too. A migration or DDL statement waits for the writers ahead of it and publishes while none is mid-transaction, so a cooperating writer never has its scope invalidated by a schema change; the schema epoch check below remains for writers that do not take turns. A write queued behind a migration that removes the table it needs fails against the new schema, as it should — that is an application error, not contention.
Structural catalog changes have a separate monotonic schema epoch. A transaction captures it when
it begins and verifies it in the same atomic step that publishes data. This prevents work staged
under an old column/default/trigger/view definition from committing after the new definition is
visible. A plain statement restarts and validates under the new schema; an explicit write scope
fails with SchemaConflictError and publishes nothing. Ordinary data commits and accelerator
build-state changes do not advance the structural epoch, so they do not create spurious schema
conflicts.
Durability
Under strict durability, durability begins when the committed write resolves. Nothing depends on a page-close handler firing, because they do not reliably fire: an abruptly terminated tab or worker recovers every acknowledged strict commit and discards only work that did not publish.
The IndexedDB and OPFS stores default to strict durability: an acknowledged commit pays the
adapter's final flush before it resolves. Use strict whenever losing an acknowledged write is
unacceptable. Open a store with durability: "relaxed" only when every acknowledged write can be
reconstructed, replayed, or recovered from another durable source. Atomicity and corruption checks
remain enabled in both modes: relaxed OPFS recovery rolls back to the last payload-verified WAL
prefix instead of opening partial state. Browser-local data that must be preserved also needs
required origin persistence to prevent automatic quota eviction and an independent synchronized or
exported copy to cover deliberate site-data clearing and device loss.