Engine

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 returned

The 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 now

It 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.

On this page