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 one line — the new Worker(...) call — because that's the only line your bundler needs to see.
Quick start
import { MinnowDatabaseClient } from "@minnowdb/core/client";
import { column, schema, table } from "@minnowdb/core";
import { createMinnow, type InferDatabase } from "@minnowdb/client";
const people = table("people", {
name: column.string().unique(),
score: column.number(),
});
const appSchema = schema([people]);
interface DB extends InferDatabase<typeof appSchema> {}
const client = new MinnowDatabaseClient(
new Worker(new URL("@minnowdb/core/worker", import.meta.url), { type: "module" }),
{ store: { kind: "indexeddb", name: "app-db" } },
);
await client.migrate(appSchema);
// The typed facade wraps the client exactly as it wraps an in-worker database.
const db = createMinnow<DB>(client, { schema: appSchema });
await db
.insertInto("people")
.values([
{ name: "Ada", score: 10 },
{ name: "Grace", score: 20 },
])
.execute();
const rows = await db
.selectFrom("people")
.select(["name", "score"])
.orderBy("score", "desc")
.execute(); // Array<{ name: string; score: number }>- 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(...)— the full database API.
Why you construct the Worker
A worker needs a script URL at runtime, and bundlers only rewrite
new Worker(new URL("…", import.meta.url), { type: "module" }) correctly when that exact
expression appears in your code — buried inside a library, it breaks differently under every
bundler. Keeping it in your code also keeps lifecycle ownership honest (you decide when the worker
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 UniqueConstraintErrorworks on the client, with its fields; stack traces point into the worker. - Functions can't cross. Construction options like
now,createId, or a custom store need a custom entry (below).
The typed facade doesn't care about any of this: compiled plans cross the channel by structured
clone, so selectFrom, typed writes, and .live() behave identically.
Buffered writers and 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 });Bundler setups
Vite and webpack 5 understand the quick-start pattern as written — they resolve the package subpath and emit a separate worker chunk. Nothing else needed.
esbuild (and Parcel 2 by default) doesn't rewrite new URL worker expressions. Bundle the
worker entry separately and point at the output:
// worker.ts — your one-line worker entry, bundled separately:
import "@minnowdb/core/worker";
// esbuild app.ts worker.ts --bundle --format=esm --outdir=dist --splitting
// app.ts:
const client = new MinnowDatabaseClient(
new Worker(new URL("./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/worker.js";
// main page:
import { MinnowDatabaseClient } from "https://cdn.example.com/@minnowdb/core/dist/client.js";
const client = new MinnowDatabaseClient(
new Worker(new URL("./db-worker.js", import.meta.url), { type: "module" }),
);Custom worker entries
The stock entry covers everything a message can carry: the store descriptor (indexeddb or
memory) plus compression, rowsPerBlock, maxCommitRetries, spillOwnerLeaseMs, and
bufferPoolBytes. For anything a message can't carry — a custom BlockStore, deterministic
now/createId — write your own entry:
// my-worker.ts
import { MinnowDatabase, exposeDatabase } from "@minnowdb/core";
import { IndexedDbBlockStore } from "@minnowdb/core/storage";
const store = await IndexedDbBlockStore.open({ name: "app-db", durability: "strict" });
exposeDatabase(new MinnowDatabase(store, { compression: "gzip" }), self, {
onDispose: () => store.close(),
});exposeDatabase() speaks the same protocol as the stock entry, so the main-thread code doesn't
change. 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: IndexedDB stays authoritative, and durability still ends at a committed transaction. See Architecture.