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();
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.
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.
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 onbeforeunload.- 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
VACUUMto 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.
Related
- Running SQLite in the browser with Wasm — the engine this page makes durable.
- Working with datasets larger than memory — when the file outgrows the tab.
- Syncing a browser database with a server — the safety net for eviction.
← Back to Databases & Persistent Storage in Wasm