Workers & multi-tab
The shipped worker entry, the main-thread client, and bundler setups.
Minnow ships both sides of the worker setup: a ready-made worker entry (@minnowdb/core/worker)
and a main-thread client (@minnowdb/core/client). Your app contributes a one-line local worker
module and the new Worker(...) call so its bundler can discover the worker as a separate entry.
Quick start
// db-worker.ts
import "@minnowdb/core/worker";// database.ts
import { MinnowDatabaseClient } from "@minnowdb/core/client";
import { column, schema, table } from "@minnowdb/core";
const people = table("people", {
name: column.string().unique(),
score: column.number(),
});
const appSchema = schema([people]);
const client = new MinnowDatabaseClient(
new Worker(new URL("./db-worker.ts", import.meta.url), { type: "module" }),
{ store: { kind: "indexeddb", name: "app-db" }, schema: appSchema },
);
// Applies `appSchema`; it also types every batch method by table name, exactly as it does on
// `MinnowDatabase`. The declaration stays on the main thread: only `migrate()` crosses the wire.
await client.migrate();
await client.execute("INSERT INTO people (name, score) VALUES ($1, $2), ($3, $4)", [
"Ada",
10,
"Grace",
20,
]);
const { rows } = await client.query("SELECT name, score FROM people ORDER BY score DESC");- The client sends its configuration to the worker on startup, so the stock entry needs none of its own.
- The channel is ordered, so you can issue calls immediately.
await client.ready()exists to surface store-open failures eagerly. - Storage access, decoding, planning, and execution all run in the worker; the main thread holds only the proxy.
- The raw layer is there too:
client.query(sql),client.insertBatch(...),client.createTable(...),client.createView(...),client.createIndex(...), andclient.buildFtsIndex(...)— the everyday database API, including the direct catalog and index helpers.
Why you construct the Worker
Bundlers rewrite new Worker(new URL("./db-worker.ts", import.meta.url), { type: "module" })
only when that exact relative expression appears in your code — a bare package name or an
expression buried inside a library cannot be analyzed reliably. Keeping the construction in your
code also makes the worker's lifecycle yours (you decide when it starts and stops) and satisfies
worker-src content-security policies from your own origin.
What changes across the boundary
- Everything is async. Members that are synchronous in the worker return promises on the
client, and getters become methods:
writer.stats(). snapshot()pins its version in the worker for the callback's lifetime — session queries cross the channel pinned to that version, so a scope observes one consistent state even while other tabs commit.migrate()takes the same schema DSL — it's serialized over the wire automatically.- Typed errors survive the trip.
instanceof UniqueConstraintErrorandinstanceof UnknownTableErrorwork on the client, with their fields; stack traces point into the worker. Thecausechain crosses too (eight links deep), a platform exception such as a quota refusal is still aDOMExceptionnamedQuotaExceededError, and a built-in subclass (TypeError,RangeError) keeps its prototype. - Results cross as columns, not rows. A query result leaves the worker as one array per
column — typed arrays for numbers, booleans, and datetimes, transferred rather than copied, and
strings as one flat text with offsets — and the client rebuilds the row objects on the main
thread. You see the same
QueryResulteither way:columnsnames,rowsof plain objects keyed in column order,Dateinstances, booleans,nulls, and numbers exactly as the engine produced them, plus the alignedcolumnDomainsvector for logical SQL types and ad-hoc JSON projections. Only the cost of a large result crossing changes: structured-cloning 20,000 row objects is several times slower than rebuilding them from columns. Live-query change events take the same path. - Cursors transfer one page per pull.
client.queryCursor()keeps scan work in the worker, applies backpressure across the RPC handle, and reconstructs only the current columnar page on the main thread. See Cursors and exports. - Query controls stay local.
signalandonStatsare functions/objects that cannot be structured-cloned, so the client removes them from the options frame. Abort becomes a small cancellation frame; stats return as an ordered event before the result. Ordinary queries, cursor queries, bufferedexecute()statements, and reads insideclient.write()support the same controls as the direct engine. The client and worker must speak the same protocol version (7 in core 0.9.0); a mismatch fails every call loudly with "Unsupported protocol version" rather than silently dropping a newer argument, so deploy the client and worker from the same package build and make sure a cached older worker cannot outlive its client. - Queues and handles are bounded. One connection admits at most 256 in-flight requests and 256 stateful handle lifecycles, including handles still opening or durably settling. Await requests and close cursors, snapshots, buffered writers, and live-query handles when they are no longer needed. Overload fails immediately instead of retaining an unbounded message queue.
- Abandoned write callbacks roll back. A worker write handle with no session RPC for
transactionIdleTimeoutMs(30 seconds by default) is removed and rolled back. Each query, execute, or batch call restarts the full interval after it finishes; a slow call in flight never expires. A later call on an expired handle fails instead of publishing its staged data. - Functions can't cross. Construction options like
now,createId, or a custom store need a custom entry (below).
Cancel a read with the same QueryOptions.signal on either side of the boundary:
const controller = new AbortController();
const pending = client.query("SELECT * FROM events ORDER BY created_at", {
signal: controller.signal,
onStats: ({ peakMemoryBytes }) => console.log({ peakMemoryBytes }),
});
controller.abort();
await pending; // rejects with an AbortErrorThe client rejects a cancelled read promptly, even if its worker cannot respond. Worker cleanup then cooperatively releases leases and temporary spill pages; local rejection does not acknowledge that cleanup. Direct-engine cancellation joins cleanup before rejecting. Neither returns a partial query result. Cancellation is checked between bounded execution and storage batches. It cannot preempt JavaScript in the middle of one synchronous batch; keep the engine in a worker when a large batch must not share the UI thread.
Each RPC has a deadline: requestTimeoutMs on the client defaults to 60,000 ms and also covers
startup. The deadline measures silence, not wall time: the worker reports every five seconds on
each call it is still working on, so a large batch write or a slow query is bounded by progress
rather than by size, up to ten times the deadline in all. The cap uses monotonic elapsed time,
so changing the system clock cannot extend it. Expiry closes the connection to
further work and rejects pending calls with DatabaseWorkerTimeoutError, or
DatabaseWorkerOutcomeUnknownError when an operation could have published before its reply was
lost. Transport failures and cancellation of a dispatched mutation use the same unknown-outcome
error. The client never replays a mutation automatically. Reopen a client and reconcile a durable
application ID (for example, a unique sale ID) before retrying; a lost reply is not proof of rollback.
The checkout recovery pattern
keeps that identity durable across a browser restart and handles concurrent retries.
Nothing that goes wrong inside the worker stays there. A failure that belongs to no call — an
uncaught exception in a timer or channel listener, an unhandled promise rejection, a background
checkpoint or collection step that failed, a multi-tab election that threw — is caught by the
worker host and delivered to the client's onWorkerError option as a
DatabaseWorkerErrorEvent: a kind (uncaught, unhandled-rejection, messageerror,
maintenance, coordination, or transport), a context naming where it happened, and the
rehydrated error. A WriteAdmissionStalledError with the context write admission means a
write has waited ten seconds for the database's writer turn without the holder changing — a
callback on this connection awaiting another write, or another tab that stopped inside one; the
wait continues. Without a handler each one is written to console.error on the main thread. A throwing diagnostic
handler is logged alongside the original error. Exceptions in loss listeners (onError,
onComplete, and onConnectionLost) are logged too, and every pending call and other listener
still receives the connection loss.
Stall detection requires an observed holder; an empty or unavailable lock snapshot does not
produce a stall report. The ten-second threshold and writer ordering are unchanged.
These reports are not fatal: the connection keeps working, and the calls it was serving settle on
their own. The Worker object's own error and messageerror events are different — they mean
the channel's state is unknown, so every pending call rejects with DatabaseWorkerFailedError
(its message names the event's message, file, and line; the thrown error is its cause) and
later calls throw it too — a worker.onerror handler of your own no longer sees those events,
since the entry marks them handled; open a new
client. The stock worker entry marks the errors it reports as handled, so the browser does not
log them a second time under the worker's name.
const client = new MinnowDatabaseClient(worker, {
store: { kind: "opfs", name: "shop" },
onWorkerError: ({ kind, context, error }) => report(`worker ${kind} in ${context}`, error),
});A direct engine has the same seam as MinnowDatabaseOptions.onBackgroundError, and a directly
opened OPFS store as OpfsBlockStoreOptions.onDiagnostic; the worker host wires both into the
event above.
A throwing onWorkerError callback is logged and contained. It cannot prevent pending calls
from settling or a failed connection from closing. Worker query results are validated before
rows are allocated; malformed vectors, null masks, offsets, or domain metadata reject the
call instead of inventing values. A result frame carries at most 1,000,000 rows, 16,384 columns,
and 4,000,000 cells. Use a streamed query for larger results or narrow an overly wide projection.
client.close({ terminateWorker: true, timeoutMs: 5000 }) attempts graceful disposal, then
terminates the worker even if disposal times out. The timeout defaults to five seconds of silence. The
worker reports while it drains resources, so a healthy shutdown may take longer, while the same
ten-timeout absolute cap still ends a worker that reports forever. A rejected close is not a
successful flush; pending buffered writes may have an unknown outcome. Repeated close calls share
one promise. If initialization or the transport already failed, its original ready() or operation
keeps that error. close() skips the impossible disposal call, removes the client's listeners, and
terminates a terminable transport when requested. With terminateWorker: false or a transport such
as MessagePort that cannot terminate, worker-side store and handle release cannot be confirmed
after that prior failure. Explicit client.write() callbacks are not replayed, and their parallel
session calls run in order before commit. A scope takes its turn as the database's one writer
before its callback runs, in order with every other write in every tab; use the callback's
session for writes and keep network requests outside it (see
write scopes). Closing a client rejects every call it
still has waiting for a turn without waiting for the tab that holds it. Snapshot queries use the
live worker scope, so an expired or closed scope cannot silently read through a replacement
lease.
Client adapters can wrap the same worker client because it implements MinnowSqlDriver. The
Kysely adapter therefore uses the same API on either side of the worker
boundary.
Buffered writers and low-level live queries
Stateful handles proxy transparently — the writer's age timer runs on the worker's clock, and live-query callbacks arrive as events:
const writer = client.bufferedWriter("people", {
maxRows: 500,
onError: (error) => console.error("background flush failed", error),
});
await writer.add({ name: "Edsger", score: 30 });
await writer.close();
const live = client.liveQueries({ channelName: "app-db-commits" });
const subscription = await live.subscribe("SELECT name, score FROM people ORDER BY score DESC", {
onChange: (result) => render(result.rows),
});
// … later
await subscription.close();
await live.close();
await client.close({ terminateWorker: true });For inferred rows, keyed changes, framework subscriptions, and Kysely composition, use the typed live-query wrapper. It uses the observer-only worker handle so the adapter executes each invalidated query once.
Bundler setups
Vite and webpack 5 understand the quick-start pattern as written: the relative URL identifies
your one-line wrapper, and its package import resolves to Minnow's published worker entry. They
emit it as a separate worker chunk. Vite's default worker format is iife, which cannot split
that chunk further, so a Vite worker built from the generic entry carries all three stores — use a
per-store entry or set worker: { format: "es" }.
esbuild (and Parcel 2 by default) doesn't rewrite new URL worker expressions. Bundle the
worker entry separately and point at the output:
// db-worker.ts — your one-line worker entry, bundled separately:
import "@minnowdb/core/worker";
// esbuild app.ts db-worker.ts --bundle --format=esm --outdir=dist --splitting
// app.ts:
const client = new MinnowDatabaseClient(
new Worker(new URL("./db-worker.js", import.meta.url), { type: "module" }),
);No bundler — module workers can't resolve bare specifiers, so use full URLs from a CDN or vendored files:
// db-worker.js — served from your origin:
import "https://cdn.example.com/@minnowdb/core/dist/engine/worker.js";
// main page:
import { MinnowDatabaseClient } from "https://cdn.example.com/@minnowdb/core/dist/engine/client.js";
const client = new MinnowDatabaseClient(
new Worker(new URL("./db-worker.js", import.meta.url), { type: "module" }),
);Per-store worker entries
The generic entry loads each storage adapter with a dynamic import() when the client's init
frame asks for it. That downloads only the store the worker opens when the bundler splits worker
code into chunks — esbuild --splitting, Vite with worker: { format: "es" }, webpack 5. A
bundler that cannot split a worker inlines all three adapters: with Vite's default iife worker
format the core 0.12.1 generic worker is about 1.60 MiB minified (453 KiB gzipped), against
1.27 MiB (366 KiB gzipped) for the IndexedDB store alone and 1.57 MiB (445 KiB gzipped) for the
two durable stores the auto entry bundles. These are minified ES2022 bundles without splitting;
the separately bundled client adds its own download cost.
When the worker always opens the same kind of store, import the entry that bundles only that store:
| Entry | Bundles |
|---|---|
@minnowdb/core/worker/indexeddb | IndexedDB store |
@minnowdb/core/worker/opfs | OPFS store |
@minnowdb/core/worker/memory | In-memory store |
@minnowdb/core/worker/auto | OPFS and IndexedDB stores, for { kind: "auto" } |
// db-worker.ts
import "@minnowdb/core/worker/indexeddb";Nothing else changes: the client sends the same descriptor, and { kind: "indexeddb", name: "app-db" } opens as before. A per-store worker refuses an init frame for any other kind, so
client.ready() — and every call pipelined behind it — rejects with an error naming the entry
that does support it:
This worker bundles only the IndexedDB store and cannot open the OPFS store; import
"@minnowdb/core/worker" for every adapter or "@minnowdb/core/worker/opfs" for the OPFS store.Choosing the store at runtime
An app that wants OPFS wherever it exists and IndexedDB everywhere else does not have to probe
for it. The auto descriptor makes the choice inside the worker, which is the only place it can
be made — synchronous access handles exist in dedicated workers alone, and not at all in Safari's
private browsing:
const client = new MinnowDatabaseClient(worker, {
store: { kind: "auto", name: "app-db", indexeddb: { durability: "relaxed" } },
});
console.log(await client.storeKind()); // "opfs" or "indexeddb"The opfs and indexeddb fields carry each store's own options and apply to whichever one
opens. The choice is made once per database name and remembered in a small IndexedDB record
(minnowdb-store-choice), so a database created on one store is never reopened, empty, on the
other: if the remembered store cannot open — OPFS in a context that has lost it — ready()
rejects with DatabaseStoreUnavailableError rather than starting over. A name with no record
that already holds a database on one store — created through an explicit { kind: "indexeddb" }
or { kind: "opfs" } descriptor before the application switched to auto, or whose record was
cleared — opens that database and records the store then, so the switch never starts an empty
one beside it. If a storage existence probe fails with a permission, I/O, or other unexpected
error, resolution rejects with that error; uncertainty is never treated as an absent database.
The choice must commit before its adapter opens. A successful IndexedDB request followed by a
transaction abort rejects the open; deleting a remembered choice also waits for commit.
When you delete the database, call forgetStoreChoice(name) from
@minnowdb/core as well, so the name can decide afresh.
auto is served by the generic entry and by @minnowdb/core/worker/auto, which bundles the two
durable stores and nothing else — the right entry when the bundler cannot split worker code.
A per-store entry refuses it, since it could only ever open one of the two.
Custom worker entries
The stock entry covers everything a message can carry: the store descriptor (indexeddb,
opfs, auto, or memory — omitted, it defaults to { kind: "indexeddb", name: "minnow" }) plus compression, rowsPerBlock, targetBlockBytes, maxCommitRetries,
spillOwnerLeaseMs, transactionOwnerLeaseMs, transactionIdleTimeoutMs, bufferPoolBytes,
executionMemoryBudgetBytes, autoCompact, autoCollect, and autoCollectDebtLimitCommits.
It loads each storage adapter only when its descriptor asks for it; whether that becomes a
separate download depends on the bundler, as described above. For a custom store, register the worker host synchronously and open the store inside its
factory. This installs the init listener before asynchronous storage work begins:
// my-worker.ts
import { IndexedDbBlockStore } from "@minnowdb/core/storage/indexeddb";
import { attachDatabaseWorker, singleStoreFactory } from "@minnowdb/core/worker-host";
attachDatabaseWorker(self, {
createStore: singleStoreFactory("indexeddb", async () =>
IndexedDbBlockStore.open({ name: "app-db", durability: "strict" }),
),
});The client still sends its database options in the init frame. singleStoreFactory() refuses
other store kinds, as the per-store entries do. Disposal closes the engine and then the store.
For function-valued engine options such as deterministic now/createId, construct a
MinnowDatabase yourself and pass it to exposeDatabase(database, self, options). If opening
its store requires an await, establish an explicit ready handshake: the worker sends a ready
message after calling exposeDatabase, and the main thread waits for that message before
constructing MinnowDatabaseClient. Opening storage before installing a listener can otherwise
lose an early init frame. attachDatabaseWorker handles this ordering for the factory example
above.
exposeDatabase closes the engine before calling its onDispose hook, so that hook only owns
the injected store. Its writeHandleIdleTimeoutMs option defaults to 30 seconds; pass the same
value as the constructed database's transactionIdleTimeoutMs when customizing either deadline.
Underneath both sits @minnowdb/core/worker-protocol: versioned, structured-clone-safe RPC where
each handle answers a fixed list of methods — never arbitrary property access.
The worker changes where work runs, not the rules: the durable store stays authoritative, and durability still ends at a committed storage write. See Architecture.