Persisting a Wasm Database to OPFS

This guide answers one task: store a WebAssembly database’s pages in the Origin Private File System so they survive reloads, restarts and crashes — and prove that they do, rather than assuming it because the code ran without error.

Prerequisites

  • [ ] A secure context. OPFS is unavailable over plain HTTP outside localhost.
  • [ ] A dedicated or shared worker. The synchronous access handle does not exist on the main thread.
  • [ ] A database build with OPFS support, such as the official SQLite Wasm distribution.
  • [ ] A browser with createSyncAccessHandle — Chrome 102+, Safari 15.2+, Firefox 111+.

What OPFS gives you that other storage does not

The Origin Private File System is a per-origin file system that is not visible to the user and not backed by the platform’s file picker. Its value for a database is one specific capability: a worker can obtain a synchronous access handle to a file and then read and write byte ranges without awaiting anything.

That matters because a database engine compiled from C expects file operations to return immediately. A storage layer built on IndexedDB has to suspend and resume around every page read, which either requires Atomics.wait on a second thread or a rewrite of the engine’s pager. With a synchronous handle, the VFS implementation is a thin shim over read, write, truncate and flush, and the engine runs at close to the speed it would on a real disk.

// what the VFS is doing underneath, simplified
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('main.sqlite3', { create: true });
const access = await fileHandle.createSyncAccessHandle();   // worker only

const page = new Uint8Array(4096);
access.read(page, { at: pageIndex * 4096 });                 // synchronous
access.write(page, { at: pageIndex * 4096 });                // synchronous
access.flush();
Why synchronous access changes the design An IndexedDB-backed file system must suspend the engine on every page read and resume it later. A synchronous access handle returns the bytes immediately, so the compiled pager runs unmodified. IndexedDB page read engine wants page suspend · await · resume bytes arrive per page, thousands of times OPFS synchronous handle engine wants page bytes arrive no suspension, no protocol, no rewrite This is the whole reason the OPFS path is fast: the compiled pager does not know it is not talking to a disk. It is also the whole reason it is worker-only — synchronous file access on the main thread would block rendering.

Opening a durable database

With the official SQLite build, all of the above is behind one constructor. The work is in the setup around it: making sure you are in a worker, that the context is secure, and that you can report clearly when you are not.

// db-worker.js
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';

const sqlite3 = await sqlite3InitModule();
if (!('opfs' in sqlite3)) {
  self.postMessage({ fatal: 'OPFS unavailable: need a secure context and a worker' });
  throw new Error('opfs unavailable');
}

const db = new sqlite3.oo1.OpfsDb('/app/main.sqlite3');
db.exec('PRAGMA synchronous = NORMAL; PRAGMA cache_size = -8000;');
self.postMessage({ ready: true, file: '/app/main.sqlite3' });

Report the failure rather than silently falling back. A page that quietly uses memory storage will pass every test you write and lose a user’s work the first time they reload.

Exclusive access and multiple tabs

A synchronous access handle is exclusive for the lifetime of the handle. The second tab that tries to open the same file does not get a degraded experience; it gets an error. That is the correct behaviour — two independent pagers writing the same file would corrupt it — but it must be designed for.

The simplest robust arrangement is leader election with the Web Locks API. One tab holds the lock and runs the database; the others detect that they lost and either proxy their work through a BroadcastChannel or show a read-only view.

navigator.locks.request('db-owner', { mode: 'exclusive' }, async (lock) => {
  startDatabaseWorker();                         // only the winner reaches here
  await new Promise(() => {});                   // hold the lock for this tab's lifetime
});

A shared worker is the other option and is cleaner when it is available: every tab connects to the same worker, which owns the only handle. Support is good on desktop and patchy on mobile, so the Web Locks approach remains the safer default.

Asking for persistent storage

OPFS data is evictable by default. Browsers clear origin data under storage pressure, and a database that represents hours of user work should not be in the evictable bucket.

const persisted = await navigator.storage.persisted();
const granted = persisted || await navigator.storage.persist();
const { usage, quota } = await navigator.storage.estimate();
report({ granted, usageMB: Math.round(usage / 1e6), quotaMB: Math.round(quota / 1e6) });

Whether the request is granted depends on the browser’s heuristics — installed as an application, bookmarked, frequently visited — and it may be silently refused. Log the outcome, and if it is refused, make sure the application has a sync path or an export so the data is not the only copy.

Verifying that data actually survives

The only meaningful test writes, restarts and reads. Do it in CI rather than by hand, because the failure is silent and easy to reintroduce.

// playwright-style
await page.goto('/app');
await page.evaluate(() => db.run('INSERT INTO notes (title, updated) VALUES (?, ?)', ['durable', Date.now()]));
await page.reload();
const n = await page.evaluate(() => db.get('SELECT count(*) AS n FROM notes').n);
expect(n).toBe(1);

await page.evaluate(() => db.close());            // clean shutdown
await context.close();                            // and a full teardown
const page2 = await context2.newPage();           // fresh context, same profile
expect(await page2.evaluate(() => db.get('SELECT count(*) AS n FROM notes').n)).toBe(1);

Add a hostile case: terminate the worker mid-transaction and reopen. A correctly configured database rolls back the incomplete transaction and loses nothing that was committed. If it instead reports corruption, the journal mode or the flush discipline is wrong and you have found it in CI rather than in production.

Three things that must not lose data A reload, a full browser restart and a worker killed mid-transaction are the three events a durable browser database must survive. The first two preserve everything committed, and the third rolls back only the incomplete work. reload handle released, reacquired everything committed survives browser restart profile reopened later survives unless evicted killed mid-write journal replayed on open loses only the open transaction Eviction is the one case no configuration prevents — request persistent storage, and keep a sync or export path for anything irreplaceable.

Housekeeping: files, size and cleanup

OPFS is a real file system and accumulates real files. Journals, temporary files and old database versions all live there, and nothing cleans them up for you.

const root = await navigator.storage.getDirectory();
for await (const [name, handle] of root.entries()) {
  if (handle.kind === 'file' && name.endsWith('.sqlite3-journal')) {
    const f = await handle.getFile();
    console.log(name, f.size);
  }
}

Two habits keep this under control. Run VACUUM occasionally — after a large delete, or on a schedule — because SQLite does not return freed pages to the file system on its own and a database that has churned heavily can be several times larger than its contents. And provide a visible way for a user to delete everything, both because it is the decent thing to do and because it is the fastest support answer when a database ends up in a bad state.

One page, from engine to disk The database asks its virtual file system to write a page. The OPFS sync access handle turns that into a real write, which is only available inside a worker. engine writes page 4 KB at an offset VFS layer translates the call sync access handle worker only origin private file durable on disk Synchronous handles are what make this work: an asynchronous write cannot satisfy a database's VFS. Only one handle per file may be open, so a second tab must coordinate rather than open its own. Call flush at transaction boundaries; without it, durability is at the browser's discretion.

Gotchas

  • createSyncAccessHandle is not a function. You are on the main thread, or in a browser without support. There is no polyfill; fall back to memory and say so.
  • NoModificationAllowedError. Another handle to the same file is open — a second tab, or a previous worker that was never closed. Close handles on beforeunload.
  • Data vanishes after a few days. Storage was evictable and the browser reclaimed it. Request persistence and check the result.
  • The file grows and never shrinks. Expected. Run VACUUM to reclaim space.
  • Everything is slower than the in-memory build. Expected, and usually by less than people fear — but a tiny page cache makes it much worse. Raise cache_size.
  • OPFS looks empty in DevTools. Some browsers do not surface it in the storage inspector. Enumerate the directory from code instead of trusting the panel.

Performance note

On a laptop, ten thousand inserts inside one transaction to an OPFS-backed database take roughly 0.5–0.9 s against 0.35 s for the same database in memory — a real but modest cost for durability. Indexed reads are close to identical once the page cache is warm, because they never reach the file at all. The setting that moves these numbers most is cache_size; the setting that moves them least is anything to do with the JavaScript around the query.

Frequently Asked Questions

Can the user see or back up these files? Not through the file manager — the Origin Private File System is private to the origin by design. Provide an export in your application if backups matter, which for most local-first products they do.

Is OPFS the same as the File System Access API? Related but different. The File System Access API reaches files the user picks on their real disk; OPFS is a private sandboxed area with no picker and no user-visible path — and only OPFS offers the synchronous handle a database needs.

What happens on iOS? Supported in recent versions, with tighter quotas and more aggressive eviction than desktop. Test there specifically, and assume persistence requests are less likely to be granted.

Should I use OPFS for anything other than the database? It is a good home for any large binary the application owns — cached model weights, downloaded media, generated exports. The asynchronous API works on the main thread for those cases, and only the database needs the worker-only synchronous handle.

How do I migrate data from an older IndexedDB-backed database? Open the old store, read the rows, and insert them into the new database inside one transaction, then delete the old store once the new one verifies. Keep the migration idempotent and record its completion in PRAGMA user_version, because users will close the tab halfway through it.

← Back to Databases & Persistent Storage in Wasm