SQLite (via @sqlite.org/sqlite-wasm) running in a
Web Worker, with a SharedWorker multiplexer that coordinates a single database leader across
all open tabs. Communication is over Comlink.
npm install @your-scope/sqlite-webworkerReplace
@your-scopewith the actual npm scope this package is published under.
dist/
sqlite-webworker.js # main entry (".")
db-worker.js # "/db-worker": defineDbWorker + helpers for your own DB worker
db-core.js # "/db-core": raw sqlite + lock helpers
*.d.ts # types for each entry
assets/
shared_webworker-*.js # cross-tab SharedWorker multiplexer (self-contained)
sqlite3-*.js # sqlite-wasm runtime
You build your own DB worker (the thing that holds the SQLite connection) by importing
db-worker / db-core — see Usage. The library ships only the cross-tab multiplexer as a
standalone asset.
The multiplexer is a standalone asset, not inlined into sqlite-webworker.js, because a
SharedWorker is keyed by (origin, name, script-URL). If it were inlined as a blob: URL,
every tab would get a different URL and therefore its own worker — defeating the point of
sharing one database across tabs. So it must be served at a known URL.
At runtime the entry loads it from an origin-absolute path (the reference is
@vite-ignored, so your bundler leaves it alone):
new SharedWorker(new URL("/assets/shared_webworker-<hash>.js", import.meta.url), ...)So the multiplexer asset must be reachable at https://your-app/assets/... (see Setup).
Note: you do need a worker-capable bundler (Vite, etc.) to build your own DB worker (
new Worker(new URL("./your.worker.ts", import.meta.url), { type: "module" })). The copy step below is only about serving the library's prebuilt multiplexer + sqlite assets.
This library requires a worker-capable bundler (Vite, or any bundler that supports
new Worker(new URL("./x.ts", import.meta.url), { type: "module" })). You use it to build
your own DB worker (see Usage).
Separately, the library's prebuilt multiplexer + sqlite assets are referenced at an
origin-absolute /assets/... path and are intentionally opaque to your bundler
(@vite-ignore), so you must make sure they are served there. Two ways:
npm install -D vite-plugin-static-copy// vite.config.ts
import { defineConfig } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
export default defineConfig({
plugins: [
viteStaticCopy({
targets: [
{
src: "node_modules/@your-scope/sqlite-webworker/dist/assets/*",
dest: "assets",
},
],
}),
],
});This copies the assets into your build output on every build, served at /assets/... with no
manual step and no stale files after an upgrade.
Copy the assets into whatever directory your server serves at /assets:
cp -R node_modules/@your-scope/sqlite-webworker/dist/assets ./public/assetsMost setups serve a public/ (or static/, www/, dist/) directory at the site root, so
public/assets/... becomes https://your-app/assets/... — which is exactly where the library
looks. Re-run this whenever you upgrade (hashed filenames change between versions); a
postinstall script keeps it automatic:
{
"scripts": {
"postinstall": "cp -R node_modules/@your-scope/sqlite-webworker/dist/assets ./public/assets"
}
}The assets must end up at the
/assets/path on the origin that loads them. If your app is served from a sub-path or a CDN, place the files so that/<your-base>/assets/...resolves.
You always supply your own DB worker (built with defineDbWorker). The lib boots one per
tab, joins the SharedWorker multiplexer, and runs leader election so exactly one tab owns
the single OPFS connection; every call is routed to that leader. There are two ways to drive
it.
A one-line worker, then ad-hoc dbExec from the main thread:
// db.worker.ts
import { defineDbWorker } from "@your-scope/sqlite-webworker/db-worker";
defineDbWorker();// main thread
import { init, dbExec } from "@your-scope/sqlite-webworker";
await init({
dbName: "my-database",
userPk: userPrimaryKey,
dbWorker: () => new Worker(new URL("./db.worker.ts", import.meta.url), { type: "module" }),
});
await dbExec({ sql: "CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, text TEXT)" });
const rows = await dbExec({
sql: "SELECT id, text FROM todo",
rowMode: "array",
returnValue: "resultRows",
});Put CREATE TABLE in setup and your queries in methods; they execute in the elected leader
worker, off the main thread. The main thread gets a typed api and never sees SQL.
// todos.worker.ts
import { defineDbWorker, dbExec, pushToTabs } from "@your-scope/sqlite-webworker/db-worker";
const listAll = (): [number, string][] =>
dbExec({
sql: "SELECT id, text FROM todo ORDER BY id",
rowMode: "array",
returnValue: "resultRows",
}) as [number, string][];
defineDbWorker({
setup() {
dbExec({ sql: "CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, text TEXT)" });
},
methods: {
async addTodo(text: string): Promise<void> {
dbExec({ sql: "INSERT INTO todo (text) VALUES (?)", bind: [text] });
pushToTabs("todos", listAll()); // fan a snapshot out to all tabs
},
async listTodos(): Promise<[number, string][]> {
return listAll();
},
},
});
export interface TodoApi {
addTodo(text: string): Promise<void>;
listTodos(): Promise<[number, string][]>;
}// main thread — no SQL, just a typed api
import { createDb } from "@your-scope/sqlite-webworker";
import type { TodoApi } from "./todos.worker";
const { ready, api, subscribe } = createDb<TodoApi>({
dbName: "my-database",
userPk: userPrimaryKey,
dbWorker: () => new Worker(new URL("./todos.worker.ts", import.meta.url), { type: "module" }),
});
await ready;
subscribe<[number, string][]>("todos", renderTodos); // live updates from any tab
await api.addTodo("buy milk");The two /db-worker and /db-core subpath exports exist so your bundler can build that worker.
See demo/counters.worker.ts + demo/counters.ts for a complete working example.
Every call crosses thread boundaries by postMessage, and each boundary is a structured
clone — a deep copy of the arguments in and the results out:
main thread → SharedWorker (multiplexer) → leader DB worker → SQLite
(a query from a follower tab is also forwarded through the leader's main thread, so up to three clones each way).
- Option A ships every query's full payload across and the entire result set back, cloned at each hop. Reading 100k rows to compute a sum on the main thread clones all 100k rows just to discard them; large payloads also jank the main thread (cloning is synchronous on the calling thread). N dependent queries = N round trips.
- Option B runs the SQL and any intermediate logic in the worker, so only the method's
small inputs and the final shaped output cross threads. That 100k-row sum becomes
SELECT sum(...)returning one number — one tiny clone; a multi-query transaction becomes one method call = one round trip. Intermediate result sets are never serialized.
Use A for small payloads and occasional queries (the overhead is negligible). Prefer B for large result sets, chatty/multi-step operations, and hot paths — reduce and aggregate before the thread boundary.
- A browser with
SharedWorker,Worker(module workers),BroadcastChannel,Web Locks, and OPFS support. - The page must be served over a secure context
(HTTPS or
localhost). - OPFS via
SAHPoolneeds these COOP/COEP headers on your document:Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp
The repo ships a demo app under demo/:
npm run dev # serve the demo
npm run build # build the library into dist/
npm run build:demo # build the demo into demo-dist/