Skip to main content

Databases in the Browser: What OPFS Actually Unlocks

· 17 min read
Gergely Sipos
Frontend Architect

The DuckDB-WASM + OPFS announcement on September 18, by Carlo Piovesan and Geertjan Wielenga, is the kind of headline that reads like a novelty — an analytical database engine, compiled to WebAssembly, persisting to a real file system inside a browser tab. But the novelty isn't DuckDB. It's the platform layer underneath it. Over the last few years the Origin Private File System (OPFS) quietly turned the browser into a place you can run a real database, not just a cache with delusions of grandeur. The interesting question isn't "can DuckDB do this?" — plenty of engines can now. It's what changed at the platform layer to make any of them possible, and whether your project should care.

The old contract: IndexedDB and its workarounds​

For most of the web's history, the browser was a hostile place to host a database. The only durable, structured client-side store was IndexedDB — and IndexedDB is an object store, not a database in the sense a backend engineer means it. It's asynchronous to the core, key-value with indexes, non-relational, and awkward to query for anything beyond "get by key" and "range over an index." You don't write SQL against it. You assemble cursor walks and hope.

That async, object-store shape is fine for what it was designed for — Firestore, for instance, uses IndexedDB as its offline persistence cache, which is exactly the "structured cache" job IndexedDB is good at. But it's the wrong substrate for a relational engine that expects to read and write bytes at arbitrary file offsets.

The canonical pre-OPFS hack was absurd-sql: run SQLite compiled to WASM, and back its virtual file system with IndexedDB by chopping the database file into blocks stored as key-value records. It worked, and it was genuinely clever, but it was a workaround for a missing primitive — emulating random-access file I/O on top of an async object store. The name was honest about it.

DuckDB-WASM lived with the same missing primitive. When it launched in 2021 it had no persistence at all — every table lived in the Wasm heap and vanished the moment the tab closed. The app-layer workaround was its own kind of contortion: serialize your tables to Parquet, stash the raw bytes in IndexedDB, and re-register them on the next load. It rebuilt state on demand, but persistence was something you bolted on by hand, never something the engine gave you.

OPFS: a real file system for the origin​

OPFS is a private, origin-scoped file system that the browser exposes to your code. "Private" is the operative word: it isn't the user's Documents folder, there's no file picker, and no other origin can see it. It's storage that belongs to your site.

You get the root directory from one call:

// Requires a secure context (HTTPS). Works on the main thread and in workers.
const root = await navigator.storage.getDirectory(); // FileSystemDirectoryHandle
const fileHandle = await root.getFileHandle("app.db", { create: true });

navigator.storage.getDirectory() returns a FileSystemDirectoryHandle, the root of your origin's private tree. It requires a secure context, and it's available on both the main thread and inside workers. This has been Baseline — available across Chrome/Edge, Firefox, and Safari — since March 2023, so it's well past the "wait for support" phase. See the MDN File System API for the full surface.

The thing to internalise: OPFS is storage, not a database. It gives you directories and files with fast, low-overhead access. What turns those files into a queryable database is an engine layered on top — and that engine needs one specific primitive to work well.

The primitive that makes DB engines possible: sync access handles​

A database engine's file layer wants synchronous, positioned I/O — and OPFS is the first browser storage API that delivers it. The primitive is FileSystemFileHandle.createSyncAccessHandle(), which returns a FileSystemSyncAccessHandle.

It comes with hard constraints, by design:

  • Dedicated Web Workers only — you cannot create one on the main thread.
  • OPFS files only — it doesn't work on user-picked files.
  • Secure context required.

Its methods are synchronous: read(buffer, { at }), write(buffer, { at }), truncate(size), getSize(), flush(), and close(). No promises, no event loop round-trips per read. MDN is explicit about the intended use: sync access handles are "suitable for significant, large-scale file updates such as SQLite database modifications."

Why does synchronous matter so much? Because engines like SQLite implement their storage layer as a Virtual File System (VFS) that expects to call read and write and get bytes back now, not eventually. Bolting an async API under a VFS that expects sync semantics is where absurd-sql had to do acrobatics. A sync access handle gives the VFS exactly the blocking, positioned I/O it wants — and because it runs in a dedicated worker, the blocking happens off the main thread, so the UI never janks. This is the same reason we push heavy work into Web Workers for performance: the main thread stays free.

db-worker.js
// Runs inside a dedicated Web Worker.
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("app.db", { create: true });

// Takes an exclusive lock by default (see modes below).
const access = await fileHandle.createSyncAccessHandle();

// Positioned, synchronous I/O — the shape a DB VFS expects.
const header = new TextEncoder().encode("SQLite format 3\0");
access.write(header, { at: 0 });
access.flush();

const buf = new Uint8Array(16);
access.read(buf, { at: 0 });

access.close();
note

A historical wart worth knowing: some older browsers wrongly shipped read, write, and friends as async methods. Every currently supporting browser implements them synchronously, as specified, and this has been Baseline since March 2023 — but you'll still find stale examples that await them. Don't.

Locking: the single-writer reality​

Creating a sync access handle takes an exclusive lock on the file by default, and that single fact shapes every browser-database architecture. The behaviour is controlled by a mode option on createSyncAccessHandle() — which MDN flags as non-standard, so treat it as a progressive enhancement, not a guarantee:

modeConcurrencyUse it for
"readwrite" (default)Exclusive — a second handle throws NoModificationAllowedErrorSingle-writer DB connection
"read-only"Multiple simultaneous read handlesRead-only queries
"readwrite-unsafe"Multiple simultaneous handles, caller must coordinateAdvanced pooling, at your own risk

The default is exclusive. Open the database read-write in one place and you own the file; a second attempt fails. This maps cleanly onto how the engines actually work — DuckDB and SQLite are both single-writer systems as a general matter, so an exclusive file lock is the honest model, not a limitation to fight.

Where it bites is multiple tabs. Two tabs of your app are two independent JS realms, and if both try to open the database read-write, one loses. That's a coordination problem you own, not something OPFS solves for you. The tools are the Web Locks API, a SharedWorker acting as a single DB owner, or explicit leader election among tabs.

caution

Two tabs cannot both hold the database open read-write. If your app supports multiple tabs — and most do, whether you planned for it or not — decide up front how you elect the single writer. Discovering this in production looks like random NoModificationAllowedErrors that only one user in twenty can reproduce.

Why not createWritable()?​

There's a second write API on FileSystemFileHandle, and it's a trap if you reach for it here. createWritable() returns a FileSystemWritableFileStream — an async stream with swap-on-close semantics: by default it writes to a temporary file that starts empty, and with keepExistingData: true the browser copies the existing bytes into that temporary file once when the stream is created. The real file is replaced only when you close().

// Main-thread capable, async, copy-on-write — great for whole-file saves.
const writable = await fileHandle.createWritable();
await writable.write(fullBlob);
await writable.close(); // atomic swap happens here

For saving a whole file — export a document, cache a downloaded asset — this is exactly right, and it works on the main thread. For a database backend it's exactly wrong. A DB does thousands of small positioned writes and expects synchronous in-place I/O against the live file; createWritable() batches writes into an async stream and publishes them only when you close(). Whole-file semantics vs. positioned in-place I/O is the whole distinction: createWritable() for the former, createSyncAccessHandle() for the latter.

The engines: SQLite, DuckDB, Postgres​

With the primitive in place, a small ecosystem of real database engines now runs in the browser. They differ in shape and in how they use OPFS:

EngineShapeOPFS story
SQLite WASM (sqlite.org)Relational, OLTPClassic OPFS VFS or opfs-sahpool (pooled sync handles, single connection, no COOP/COEP needed)
DuckDB-WASMColumnar, analytical / OLAPIn-process analytical engine; persists to OPFS via sync access handles in a worker; single-writer
PGlitePostgres (relational)Postgres compiled to WASM (ElectricSQL team); persists to OPFS via a WASM VFS in a worker

A deployment detail worth calling out: SQLite's opfs-sahpool VFS pre-opens a pool of sync access handles and multiplexes a single connection over them. Beyond performance, it sidesteps the COOP/COEP cross-origin-isolation requirement that the classic OPFS VFS depends on — which is a genuine win, because cross-origin isolation is invasive to enable and can break third-party embeds. Just don't misread the win: opfs-sahpool still runs its sync access handles in a dedicated worker, so "no COOP/COEP" doesn't mean "usable on the main thread." If your stack can't easily become cross-origin isolated, opfs-sahpool is often the difference between "ships" and "doesn't."

The through-line across all three is the same one we keep landing on: capability is moving into the client. We wrote about that shift more broadly in what's new in Chrome (I/O '26) — a browser-side database is the storage-layer expression of the same client-side-first trend.

DuckDB-Wasm in practice​

The announcement is concrete, and worth walking through, because it turns everything above into a two-line API. Persistence is a property of open():

await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});

The opfs:// prefix is the whole trick. It tells DuckDB-Wasm's file-system layer to resolve the path against the origin's OPFS instead of the in-memory Emscripten FS. What you get back is a standard .duckdb file with a write-ahead log and checkpoints — the same on-disk format native DuckDB writes. Opening it creates analytics.duckdb and its analytics.duckdb.wal; builds from 1.33.1-dev64.0 onward also drop two empty helper files, .wal.checkpoint and .wal.recovery. It survives reloads and browser restarts, and it's genuinely portable: pull the .duckdb file out and open it with the DuckDB CLI or Python client, and it just works. (The specifics here were tested with DuckDB-Wasm 1.32.0 and 1.33.1-dev64.0.)

caution

Pin an exact, known-good version. The build npm currently serves as latest (1.33.1-dev57.0) creates the OPFS files but never writes to them — it canonicalizes the path to opfs:/analytics.duckdb with a single slash, which no longer matches the OPFS handle, so nothing persists and you get no error. Install a pinned version, e.g. npm install @duckdb/duckdb-wasm@1.32.0, rather than a mutable tag.

Durability: checkpoint like you mean it​

DuckDB writes to OPFS exactly the way native DuckDB writes to disk: committed transactions append to analytics.duckdb.wal, and the main file only updates at checkpoint time. A checkpoint fires automatically when the WAL crosses checkpoint_threshold (16 MB by default), on a clean close, or when you run CHECKPOINT explicitly. That "clean close" caveat is the catch — a browser tab is almost never closed cleanly. The user closes the tab, the phone kills the backgrounded page, the laptop lid comes down. Shutdown code you attach to beforeunload is not a plan.

So two rules. Run CHECKPOINT after any batch of writes you can't afford to lose — it's the only way to be certain the data reached the main file. And checkpoint per batch, not per statement, because a large WAL slows the next open() (it has to replay the log before serving the first query).

INSERT INTO transactions SELECT * FROM staging;
-- Force the main file to update now, not "eventually":
CHECKPOINT;

If you'd rather trade write throughput for maximum safety, flip the threshold once after connecting so every statement checkpoints:

SET checkpoint_threshold = '0KB';

And when you do get a clean exit — a deliberate "close database" action — run the full sequence: CHECKPOINT → conn.close() → db.terminate().

Data files and caching​

The same opfs:// prefix works for data files, which unlocks a nice caching pattern: load a remote dataset once, then serve it locally forever after. CREATE TABLE IF NOT EXISTS ... AS SELECT * FROM 'https://.../orders.parquet' fetches the remote file via HTTP range requests on the first load only; later loads read straight from the persistent DB with no network request. Derived results cache the same way — COPY (...) TO 'opfs://cache/monthly_totals.parquet' writes into OPFS (nested dirs like cache/ are created on demand) and SELECT * FROM 'opfs://cache/monthly_totals.parquet' reads it back.

How those OPFS file handles are managed is a knob on open(). Pass opfs: { fileHandling: 'auto' } and DuckDB scans each statement for single-quoted 'opfs://...' literals, registers those files before running it, and drops the handles afterward — convenient for one-off reads. The default is manual: call db.registerOPFSFileName('opfs://...') before and db.dropFile('opfs://...') after. Manual is more work but avoids re-acquiring an access handle on every statement, which matters for an app firing many small queries. Either way, remember a file can be held by only one handle at a time — drop your registered files before another connection or instance tries to open them.

Getting data out​

DuckDB-Wasm can't yet move files in and out of OPFS directly, but you don't need it to — the database is a plain file. Before you download it, force a checkpoint and close DuckDB so any committed rows still sitting in analytics.duckdb.wal are merged into the main file and the OPFS handle is released:

await conn.query('CHECKPOINT');
await conn.close();
await db.terminate();

const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle('analytics.duckdb');
const file = await handle.getFile(); // a Blob you can download

Or export a table with COPY transactions TO 'opfs://export/transactions.parquet' (FORMAT parquet, COMPRESSION zstd). The reverse is a genuinely useful trick: ship a pre-built .duckdb file with your app, copy it into OPFS on first launch, and open it — users get a full local dataset with no import step.

Persistence and quota: your data can still vanish​

OPFS is durable, not permanent — and the difference will bite you if you assume otherwise. Origin storage is subject to eviction: under storage pressure, a browser can clear "best-effort" data for origins the user hasn't engaged with recently.

Three APIs govern this, and you should treat them as general tools rather than sources of hard numbers:

  • navigator.storage.persist() requests that your origin's storage be exempt from eviction; it's best-effort and may be granted or refused.
  • navigator.storage.persisted() tells you whether you currently have that exemption.
  • navigator.storage.estimate() returns { usage, quota } so you can see roughly how much you've used and how much you're allowed.

See MDN's Storage API for details. We're deliberately not quoting quota figures — they vary by browser, disk, and platform, and any number here would be wrong somewhere.

The DuckDB team frames the same reality as a design rule: treat OPFS as a fast local cache and working-state store, and keep the durable source of truth somewhere stable. Sync the persistent DB back to a DuckLake catalog or plain files on object storage via s3:// paths, and OPFS eviction stops being a data-loss event and becomes a cold cache.

caution

A browser database is not a backup. Persisted storage is exempt from automatic eviction, but users clear site data, reinstall browsers, and switch devices. If the data matters, it must live somewhere the user can't casually wipe — which means syncing to a backend.

Syncing with a backend​

If OPFS makes the browser a first-class data host, the obvious next question is how that local data stays in step with a server. This is the local-first problem, and it has a real (if young) ecosystem:

  • Sync engines: ElectricSQL and PowerSync specialise in syncing a server database down to local SQLite/Postgres and back. Zero and Replicache (both Rocicorp) take a query-sync/mutation approach.
  • CRDTs: Yjs and Automerge give you conflict-free merges for collaborative, offline-tolerant state — powerful, but a different data model than a relational DB.
  • Change data capture: streaming a database's logical replication / CDC feed to clients so local copies stay current.
  • Conflict resolution: the unavoidable hard part. Last-write-wins is simple and lossy; CRDTs and custom merge logic preserve more but cost more. There's no free option, only a choice of tradeoffs.

The foundational reading here is Ink & Switch's "Local-first software" essay, which framed the whole space.

Honest take: most projects don't need a browser database, and shouldn't adopt one to get local-first vibes. If your real need is "keep server state fresh and cached in the client," that's a server-state problem, and TanStack Query solves it with a fraction of the complexity — no WASM engine, no worker, no sync protocol. Reach for OPFS-backed databases when the workload genuinely demands local querying at scale, not when you want offline caching.

When to reach for this (and when not to)​

Reach for a browser database when:

  • You need to run real queries (SQL, joins, aggregations) over a meaningful dataset locally, with no network round-trip per query.
  • The app must work fully offline and stay useful, not just show a cached last-known state.
  • You're doing client-side analytics over data that's expensive to ship to a server repeatedly — the DuckDB-WASM sweet spot.

Don't reach for it when:

  • Your actual need is "cache server responses and revalidate" — use TanStack Query.
  • A little structured local state would do — IndexedDB or even localStorage is far less machinery.
  • The data must be authoritative and safe — the source of truth belongs on the server; the browser copy is a replica at best.

Deployment gotchas to plan for:

  • Secure context (HTTPS) is mandatory — including for the APIs to exist at all.
  • Sync access handles run in a dedicated Web Worker — architect the DB as a worker from day one; retrofitting is painful.
  • COOP/COEP may be required depending on the engine and VFS — SQLite's opfs-sahpool avoids it, the classic OPFS VFS does not.
  • Call persist() early and check persisted(), so your data isn't silently evictable.
  • Own the single-writer problem across tabs with Web Locks, a SharedWorker, or leader election.

Further reading: