SQLite in a Web Worker is not optional
Scheduled8 min read By NT²
A local-first vault needs a real relational engine on device. Putting that engine on the UI thread makes every page query compete with paint and input. NT² Vault runs SQLite in a Web Worker, behind typed messages and a single lock chain, so the list can stay responsive while the database stays honest.
SQLite in a Web Worker is not optional
The claim is sharp: for a browser vault that uses file-backed SQLite, the database must live in a Web Worker—not on the UI thread.
That is not a style preference. It is the concurrency boundary that makes local-first feel like a native app instead of a spreadsheet that freezes while it “thinks.” NT² Vault encrypts structured items on device, pages the list from SQL, and opens a durable database file in the Origin Private File System. Those operations are real work. If they share the same thread as scrolling, typing, and layout, the product pays for every commit with dropped frames.
We do not treat the Worker as an optimization you bolt on after the demo. It is part of the storage architecture. The UI asks. The Worker owns SQLite. A lock serializes concurrent calls. The list stays a list.
The constraint: sync SQLite and a responsive UI cannot share one thread
Browser vaults that choose SQLite usually want three things at once: durable transactions, predictable page queries, and an interface that stays calm under load. Those goals collide if the engine runs where Reactivity and paint already live.
SQLite in WebAssembly is not free. Opening a vault, migrating schema, counting filtered rows, fetching a page, updating full-text indexes, and flushing a commit all spend CPU and file I/O. On the main thread, that work sits in the same event loop as pointer events, keyboard input, CSS layout, and Svelte updates. A “local” query that takes tens of milliseconds still steals the frame budget. A commit that flushes harder steals more. Users experience local-first as hitching—ironically, because the device is doing exactly what they asked.
There is a second, sharper constraint for our storage path.
The vault database is a real file under OPFS, opened through a cooperative synchronous virtual file system. Synchronous access handles—and the VFS that depends on them—are available in workers, not as the main thread’s happy path. Putting SQLite on the UI thread would not only jank the interface; it would fight the filesystem model we chose so SQLite can behave like SQLite.
Even if you ignore OPFS and imagine an asynchronous VFS, overlapping UI actions remain dangerous. Two clicks, a search debounce, and a background soft refresh can all want the database at once. Without a single owner and a serialized queue, the VFS sees interleaved work it was never designed to absorb. Corruption is not the only risk. Silent wrongness—partial updates, torn reads, mysterious reopen failures—is worse, because it looks like “the vault is flaky” rather than “we raced ourselves.”
So the constraint has two faces:
- Responsiveness: heavy WASM and file I/O must not own the frame loop.
- Correctness: SQLite access must be owned and ordered, not sprinkled across every component that feels like querying.
Local-first does not mean “run the database wherever JavaScript happens to be.” It means the device owns the truth, and each thread owns a job it can finish without sabotaging the others.
The design: dedicated Worker, typed RPC, one lock chain
NT² Vault gives SQLite a home: a dedicated Web Worker that loads the synchronous WASM build and attaches it to the cooperative OPFS VFS. The vault file has a concrete path per vault. Attachment ciphertext lives beside it as separate files. The Worker is the only place that opens that SQLite handle for the unlocked Writer session.
The UI never imports the WASM module into the document context and “just awaits a query.” Repositories and domain code speak a typed message protocol: open, close, run statements, fetch pages, count, and the other storage contracts the product needs. Responses cross the boundary as structured results. Failures cross as structured errors. The application layer stays agnostic about whether the physical file is OPFS in the browser or a native file on desktop—the Worker (or its platform equivalent) fulfills the same contract.
Inside the Worker, access is serialized.
Parallel UI actions are normal: scroll near the end of a virtual list while a filter settles, save an item while category counts refresh, lock soon after a search. Those calls enter a promise lock chain. Nested work inside an open transaction may deepen the lock; concurrent top-level callers wait their turn. The VFS therefore sees ordered database work instead of a pileup of overlapping mutations. Serialization is not a performance boast. It is how we keep a sync WASM file engine from seeing two writers inside one tab.
flowchart LR
UI[SvelteKit UI thread]
RPC[Typed worker RPC]
Lock[Serialized SQLite lock]
WASM[wa-sqlite sync WASM]
VFS[Cooperative sync OPFS VFS]
DB[(vault.sqlite)]
UI --> RPC
RPC --> Lock
Lock --> WASM
WASM --> VFS
VFS --> DB
That pipeline pairs with two siblings elsewhere in the stack.
Paging and virtualization keep the UI from hydrating the whole attic into reactive state. The Worker can answer cheap page and count queries while the document paints a sliding window. Moving SQLite off-thread does not help if the UI still mounts ten thousand rows; the Worker and the list architecture are complementary.
One Writer tab per vault keeps a second document from opening a competing handle on the same OPFS file. The Worker serializes work inside the Writer. Multi-tab policy chooses which tab may own the Writer role. Confusing those layers—letting every tab run its own SQLite Worker against the same file—recreates the multi-writer problem at a lower altitude.
Unlock follows the same boundary. Deriving keys and verifying the local password verifier are client-side. Opening the vault database is Worker-side. The UI thread coordinates the session; it does not become the database runtime for the duration of unlock.
The trade-off: message latency and a harder debug story
Moving SQLite into a Worker is not free.
Every query pays a message hop. Small reads that would be a function call on the main thread become structured RPC. Tooling must inspect Worker logs, not only the document console. Stack traces span two realms. Cold start includes Worker boot and WASM instantiation before the first page can return. Engineers who want “just db.prepare in the component” will find the architecture stubborn.
We accept that cost.
What we get back is a product that can grow without turning unlock and scroll into a CPU auction. Page queries and commits spend time where they belong. The UI thread spends time on what the user is looking at: list motion, form focus, copy feedback, lock transitions. When a vault has thousands of items, the difference is not theoretical. It is whether local-first feels local or feels busy.
There is also an honesty trade-off. We do not pretend the Worker erases all latency. A heavy schema step or a large backup traversal still takes wall time. The win is that those jobs do not freeze input while they run, and that overlapping UI actions cannot casually corrupt the VFS by racing each other. Responsiveness is about keeping the interface alive and the database ordered—not about claiming every SQL statement is free.
Some teams try a middle path: keep SQLite on the main thread “for simplicity,” then sprinkle requestIdleCallback or chunked awaits around queries. That softens symptoms without fixing ownership. Idle callbacks do not give you synchronous OPFS handles. Chunking does not serialize two writers. Convenience on day one becomes hitching and support noise on day one thousand.
What we refuse
Architecture is clearer when the refusals are explicit.
We refuse to run vault SQLite on the UI thread. A demo that opens the WASM module in the document context is not a shipping architecture for a structured vault.
We refuse unserialized parallel access to the Worker database. If two domain paths need the file, they queue. Nested transactional depth is deliberate; overlapping top-level writes are not.
We refuse a silent main-thread fallback. Shipping two concurrency models—“Worker when possible, main thread otherwise”—creates two performance profiles and two failure modes. Capability checks belong at the OPFS and browser-support boundary, not as a quiet downgrade that puts sync SQLite back on the frame loop.
We refuse treating the Worker as a second product. Domain rules, encryption, and list paging stay in the application architecture. The Worker owns storage execution. It is not a dumping ground for business logic that was inconvenient on the UI side.
We refuse multi-writer Workers across tabs. One unlocked Writer owns the database connection path for that vault. Followers observe; they do not open a competing SQLite runtime against the same file.
These refusals are not anti-performance tuning. They are how a local vault remains both correct and usable when the item count stops being a screenshot and starts being a life.
Keep the engine off the frame loop
A local-first vault still needs ordinary product virtues: calm scrolling, unlock that does not freeze the page, search that does not hitch the caret, and commits that do not steal the next paint. Those virtues do not come from wishing SQLite were lighter. They come from giving SQLite a Worker, giving the UI a typed door into that Worker, and locking concurrent callers so the filesystem underneath stays coherent.
That is the same stack story as storing the vault database in OPFS instead of simulating a file through IndexedDB, paging the list instead of hydrating every row into Svelte state, choosing full-text search when titles need it, and electing a single Writer when more than one tab is open. Storage location, query shape, and thread ownership are one architecture—not three optional upgrades.
For the broader local-first product shape, read Why host a heavy server when a PWA can do everything locally?. For the file boundary under the Worker, see why vault SQLite lives in OPFS, not IndexedDB. For how the UI stays light after queries return, see never load the whole vault into Svelte state and FTS5 for titles; table scan when filters win. For multi-tab ownership around the same file, see one unlocked vault per tab — by design.
If that model fits how you want a private vault to work, you can try NT² Vault or read more at nt2.me.
Last updated 2026-09-16