Memory
The execution budget, spilling, and the buffer pool.
A browser tab does not get to use all the memory it likes. Two separate mechanisms keep a database inside a bound you choose: a per-execution budget with spilling, and a byte-bounded cache of decoded blocks.
The execution budget
Every statement has a 64 MiB modeled execution budget by default. Override it for one statement:
await db.query(sql, { executionMemoryBudgetBytes: 32 * 1024 * 1024 });The budget covers the modeled vectors, row-index arrays, grouping and result payloads, and ordering buffers — the parts the engine allocates and can therefore account for. It does not cover JavaScript container overhead, the lifetime of the result you are handed, or the browser's own allocator overhead.
When an operator would exceed the budget, it spills to storage and continues. A sort, a hash join, or a grouping over more data than fits writes runs to temporary pages and merges them. The query gets slower; it does not fail.
If a statement cannot proceed even with spilling, it throws QueryMemoryBudgetError rather than
letting the tab die.
The Streamed mutation replay label identifies the state needed to apply updates, deletes,
and upserts during a scan: a bitmap with one bit per historical scan row, and about eight bytes
per row an update or upsert changed. Repeated replacements discard superseded patches, and
temporary data is released between scan windows. A query keeps those patches only while they fit
a quarter of its budget. Past that, it replays them one range of rows at a time as the scan
reaches each range, so a large unmerged delta makes a query slower, never a failed one, and the
table is queued for compaction.
Only the bitmap must always fit: one bit per row, 64 MiB for half a billion rows.
import { QueryMemoryBudgetError } from "@minnowdb/core";Measuring what a query used
await db.query(sql, {
memoize: false,
onStats: (stats) => {
console.log(stats);
},
});The engine can report its own peak because it reserves before it allocates — a measurement neither
the storage layer nor a caller could take from outside. MinnowDatabaseClient routes the callback
as a worker event, so the same option works without trying to clone the function. A result-memo hit
reports a peak of 0 because it performs no new query execution. A SELECT through
execute(sql, params, { onStats }) reports the same way.
Cancelling a query
query(), queryCursor(), execute(), and queries inside write() accept an AbortSignal:
const controller = new AbortController();
const result = db.query(sql, { signal: controller.signal });
controller.abort();
await result; // rejects; no partial QueryResult is returnedThe signal is checked between bounded scan, execution, and spill-storage batches. Cancellation
in the direct engine releases the reader lease and removes temporary spill pages before rejection.
The worker client rejects locally and sends a cancellation frame; worker cleanup follows asynchronously, so an ordinary materialized query does not need a cursor handle just
to be cancellable. A SELECT through execute() cancels exactly like a query(); any other
statement checks the signal once before it starts running, so an already-aborted execute()
never mutates anything, and a mutation that has begun publishes completely or fails. Losing its worker reply can leave
the outcome unknown; see worker deadlines and cancellation.
The buffer pool
Separately from execution, decoded blocks are cached so a warm scan does not re-read and re-decompress storage:
const db = new MinnowDatabase(store, { bufferPoolBytes: 64 * 1024 * 1024 });
db.bufferPoolStats(); // what is resident right nowIt holds decoded blocks, ready-to-read column batches, recorded value ranges, and reusable subquery results. Every entry belongs to an exact block or table version, so the cache cannot serve old data as new. Replaced entries simply stop matching and age out of the size-limited cache.
The modeled pool charge includes each artifact's payload, its key at two bytes per character, and fixed entry overhead. Long SQL and plan identities therefore consume the same budget as the results they identify. This is a residency model, not a measurement of the JavaScript heap. Queries that read volatile functions or the clock, including through nested views, do not reuse result memos.
Derived results, including window output, build their column vectors directly from result rows without retaining an intermediate value array for every column. Window execution also omits peer buffers when all functions sharing an ordering can operate by row position alone. Keyed reads use the pool's block vectors directly; they add no separate row cache or index. Eligible keyed UPDATEs load the selected row through the bounded point reader, then evaluate assignments through the ordinary query kernel. This path retains the usual predicate, visibility and type checks.
0 disables it, which is the right setting for a one-shot import that will never re-read what it
writes.
Spill cleanup
Spilled pages are owned by a lease that is renewed while the query runs, so a tab that disappears mid-query leaves pages that a later session can identify as abandoned and reclaim:
await db.cleanupQuerySpill();Worth calling at startup in a long-lived application. It is bounded work and safe to run concurrently with queries.
Choosing numbers
The defaults — a 64 MiB buffer pool and a separate 64 MiB per-statement execution budget — suit an
application working over tens of megabytes of data. Lower either on a constrained device or when
many databases are open at once. Set the database-wide default with
new MinnowDatabase(store, { executionMemoryBudgetBytes }); pass null there only when an
application deliberately accepts unbounded execution memory. The same database default applies
to mutation selection, triggers, subqueries, and read-your-writes scopes, not only public SELECTs.
Window execution reserves result, sort, peer, and aggregate buffers against the query budget. Database window passes yield to allow cancellation. These working buffers currently require resident memory; they do not spill. Result memos use bounded dependency-table generations and a structural schema identity, so unrelated-table writes preserve warm results. Missing commit history invalidates the memo rather than guessing.