Skip to content

Why our vault SQLite database lives in OPFS, not IndexedDB

9 min read By NT²

SQLite wants a file. A privacy vault wants durable relational storage that stays fast as its item count, indexes, and encrypted attachments grow. Putting a SQLite virtual file system on IndexedDB can bridge those worlds, but it makes the bridge part of every database operation. For NT² Vault, the browser vault file belongs in the Origin Private File System.

Why our vault SQLite database lives in OPFS, not IndexedDB

Our position is direct: the primary SQLite database for a browser vault belongs in the Origin Private File System (OPFS), not IndexedDB.

That is not the same as saying IndexedDB is bad. IndexedDB is a capable browser database with a broad compatibility history. It is a good fit for structured records, key-value state, caches, and small local indexes. We still use it where those semantics match the job.

But an encrypted vault is not one small object store. It is a relational system with transactions, foreign keys, schema evolution, paged queries, and full-text search. SQLite already knows how to provide those things. The question is whether to let SQLite operate on a file-like storage system or to emulate its file operations through a different transactional database.

We tried the second shape. It worked well enough to prove the local-first architecture, but “works” is not the same as “is the right long-term storage boundary.”

The constraint: SQLite over IndexedDB pays an impedance tax

WebAssembly does not automatically give SQLite a durable file on the web. SQLite talks to its environment through a Virtual File System, or VFS. A browser VFS has to translate SQLite’s expectations—open a file, read bytes at an offset, write pages, truncate, sync, lock, and close—into APIs the browser actually exposes.

An IndexedDB-backed VFS translates those operations into IndexedDB records and transactions. This is clever engineering, and it gives applications a practical route to persistent SQLite in browsers that do not expose conventional filesystem access.

The problem is the semantic gap.

SQLite thinks in database pages, journals, locks, and durability barriers. IndexedDB thinks in object stores, keys, values, and asynchronous transactions. The VFS must continually map one model onto the other. Each layer has its own transaction lifecycle and failure behavior, so the adapter is not merely a storage location. It becomes a database-inside-a-database translation layer.

For a small dataset or a modest query pattern, that cost may be acceptable. A vault applies more pressure:

  • item creation and editing need atomic relational updates;
  • list views need stable pagination rather than loading every item into memory;
  • full-text indexes need regular updates and efficient reads;
  • schema changes need predictable transaction behavior;
  • lock, close, reopen, backup, and deletion paths all need to agree about what durable means;
  • encrypted attachment metadata must remain consistent with separately stored ciphertext.

Performance is only half of the concern. Durability is the other half. If a storage adapter encourages relaxed SQLite settings or journal compromises to fit the host API, the application is trading away part of the reason it chose SQLite. A vault should not have one definition of a committed transaction on desktop and a weaker definition in the browser simply because its file was being simulated through an object database.

The deeper lesson was not “optimize IndexedDB harder.” It was that our relational database should sit on the browser primitive whose semantics are closest to a private application file.

The design: wa-sqlite on a cooperative synchronous OPFS VFS

OPFS is an origin-scoped private filesystem. Its files are not presented to the user as ordinary documents, and one website cannot browse another origin’s storage. For an installed or browser-based local-first application, that is exactly the kind of boundary a private runtime database needs.

NT² Vault runs SQLite as WebAssembly through wa-sqlite. In the browser, a dedicated Web Worker owns the database and connects it to OPFS through a cooperative synchronous VFS. The vault database has a literal per-vault path:

vaults/{vaultId}/vault.sqlite

Encrypted attachment frames live beside the database in that vault’s private directory:

vaults/{vaultId}/attachments/{attachmentId}.{chunkIndex}.bin

SQLite remains authoritative for relational metadata. Attachment ciphertext remains in files designed for binary I/O. The separation is intentional: the database can query attachment names, sizes, ownership, and encryption metadata without turning large encrypted binaries into database rows or IndexedDB values.

The architecture has a clear ownership chain:

flowchart LR
    UI[SvelteKit UI] --> RPC[Typed worker RPC]
    RPC --> Worker[Dedicated Web Worker]
    Worker --> SQLite[wa-sqlite WASM]
    SQLite --> VFS[Cooperative sync OPFS VFS]
    VFS --> DB[(vault.sqlite)]
    Worker --> Blobs[Encrypted attachment files]

The worker boundary matters for two reasons.

First, synchronous OPFS access handles are available in workers, not on the browser’s main thread. The VFS can perform file-like reads, writes, truncation, flushes, and closes without turning every low-level SQLite operation into an application-level asynchronous dance.

Second, database work should not block rendering or input. SQL execution and filesystem I/O stay behind a typed message boundary while the interface remains responsive. The application’s repositories and state layer do not need to know whether the physical file lives in OPFS or a native desktop directory. They talk to the same storage contract.

This does not mean every tab should open the file and race to write it. One writer owns the database handle for an unlocked vault. Other tabs follow the writer rather than inventing a second mutation path. OPFS gives us appropriate file semantics; disciplined ownership gives us appropriate concurrency semantics.

We also use SQLite’s full durability posture on this path. A commit should mean that SQLite has performed the durability work it expects, not that an adapter has approximated it with a convenient collection of object-store writes.

IndexedDB still has a job—just not SQLite’s job

Storage decisions become ideological when every primitive is expected to win every comparison. That is not our design.

The device needs a small vault picker before any vault is unlocked. It contains device-local entries such as a display name and an opaque vault identifier. This is tiny, simple, structured state. It is not the authoritative vault profile, and it does not need SQL joins, full-text search, or file-oriented I/O.

IndexedDB fits that job well.

This distinction is important:

  • IndexedDB answers: “Which local vaults can this device offer in the picker?”
  • OPFS-backed SQLite answers: “What is inside this vault, and what is the durable relational state?”

Keeping the picker separate also avoids a circular dependency. The application can discover local vaults before opening a vault database. Once the user selects one, the application opens that vault’s own SQLite file in OPFS.

Using both browser storage APIs is not inconsistency. It is choosing each API for the data model it naturally supports.

The trade-off: a real browser support floor

The OPFS design has a cost: it requires modern browser support for synchronous access-handle behavior.

The important detail is not merely whether navigator.storage.getDirectory() exists, or even whether the browser exposes createSyncAccessHandle(). The methods used by a synchronous VFS—such as reading, writing, truncating, flushing, and checking file size—must actually be synchronous.

Safari and iOS are a useful edge case. Versions before 16.4 exposed an earlier form of the API in which operations returned promises. That surface may look supported during a shallow feature check, but it cannot satisfy a synchronous SQLite VFS. If allowed to proceed, the mismatch can emerge later as a vague disk I/O failure while the database is opening or creating its schema.

We prefer an honest compatibility boundary. The worker probes the required behavior before SQLite touches the VFS. If the access-handle methods do not have the semantics we require, the vault does not open and the application presents a clear browser-support error.

That makes Safari 16.4 or later the practical floor on Apple platforms for this storage path. Other browsers must likewise provide OPFS with synchronous access handles in a worker.

There is no silent fallback to the former IndexedDB VFS.

Fallback sounds user-friendly, but here it would create two durability models, two performance profiles, and two sets of storage-specific failure modes. A user could unknowingly create a vault on the legacy path, then experience different behavior depending on browser version or capability detection. Support and recovery would become less predictable precisely where predictability matters most.

A hard capability check is less convenient than pretending all storage backends are equivalent. It is also more truthful.

What we refuse: dual-write migration theater

It is tempting to make a storage change look painless by dual-writing every mutation to both systems, adding a background converter, maintaining dual-read logic, and declaring that nobody will notice.

For an encrypted local vault, that approach creates more risk than reassurance.

Dual write means every create, update, delete, attachment change, and schema transition must succeed in two persistence systems with different transaction semantics. If one succeeds and the other fails, the application needs reconciliation rules. If the browser closes between writes, it needs recovery rules. If both copies remain, deletion and data-retention behavior become harder to explain. The migration machinery becomes a long-lived second storage engine hidden inside the product.

That is not durability. It is ambiguity.

NT² Vault treats OPFS as the greenfield browser vault location. We do not silently read an old IndexedDB-backed SQLite database, copy it behind the user’s back, and keep both paths alive. Portable backup and import are the explicit recovery bridge when data needs to move into a fresh vault environment.

This choice was possible because the product was still establishing its browser storage foundation. After a mature product has shipped years of user data, migration obligations are different. The general lesson is not “never migrate.” It is “do not build a permanent dual-storage architecture merely to avoid naming a clean break.”

The browser vault file is now the OPFS file. One source of truth, one durability model, one deletion boundary.

A file-oriented foundation for a local-first vault

Local-first is not achieved by placing some state in a browser API and adding an offline badge. The local system has to be coherent enough to act as the primary system: transactional, searchable, recoverable, and explicit about its compatibility limits.

For NT² Vault, that means letting SQLite remain SQLite. wa-sqlite supplies the relational engine. A dedicated worker keeps database work off the interface thread. The cooperative synchronous OPFS VFS gives that engine file semantics suited to its design. IndexedDB stays in the architecture, but only for the small device index it handles naturally.

The result is less magical than a universal storage abstraction, and that is a strength. Each boundary says what it owns.

For the broader architecture—local encryption, offline operation, and the optional blind edge—read Why host a heavy server when a PWA can do everything locally?.

If that trust model fits how you want a private vault to work, you can learn more at nt2.me.

Last updated 2026-08-01

Related stories