12k
All articles

Running SQLite in the Browser With OPFS

Use SQLite in the browser with OPFS: set up a Worker, choose opfs or opfs-sahpool, and avoid silent in-memory fallbacks.

OpenReplay Team
OpenReplay Team
Running SQLite in the Browser With OPFS

SQLite compiled to WebAssembly runs entirely in memory by default, so every row you write disappears on page refresh unless the database is backed by a persistent VFS, and the Origin Private File System (OPFS) is what provides that persistence in the official build.

Getting there takes a little more than one import and a query. This article walks through the actual setup cost with the official @sqlite.org/sqlite-wasm package: opening a persistent database in a Worker, wiring it to the UI, the two constraints that trip up first implementations, and how to choose between the two production-viable VFSes.

Key Takeaways

  • The official build is published on npm as @sqlite.org/sqlite-wasm and, unlike sql.js, is maintained by the SQLite project itself with OPFS persistence built in.
  • OPFS synchronous access handles exist only in Worker threads, so an OPFS-backed database can never be opened on the main thread.
  • The default “opfs” VFS requires the COOP and COEP headers because it depends on SharedArrayBuffer; the “opfs-sahpool” VFS needs no headers at all.
  • opfs-sahpool is the fastest OPFS option for batch work but allows only one open connection, so a second tab opening the same database fails.
  • Never fall back silently to an in-memory database when OPFS is unavailable; that keeps the app working while discarding everything the user saves.

What Does OPFS Give SQLite in the Browser?

OPFS is a sandboxed, origin-scoped filesystem that gives SQLite what it actually needs: synchronous, byte-level file access that survives reloads. Without it, the Wasm build holds the database in memory and a refresh wipes it. With it, you get a real durable SQL database on the client, which is the piece that makes modern SQLite features usable in a browser context at all.

Use the official package. There are older community Wasm ports, but @sqlite.org/sqlite-wasm is the SQLite project’s own Wasm build, republished as an ES module. The only thing layered on top is a set of TypeScript types. The persistence documentation describes several storage backends; the two that matter in practice are the “opfs” VFS and the “opfs-sahpool” VFS. Other routes exist (kvvfs over localStorage, an “opfs-wl” variant, WAL mode with exclusive locking), all covered in that same document.

Opening a Database Inside a Worker

Install the package, then do all database work in a dedicated Worker. This example uses the “opfs-sahpool” VFS, which must be installed explicitly with await sqlite3.installOpfsSAHPoolVfs(). It normalises database names to absolute paths, so use the leading slash consistently:

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

let db;

async function init() {
  const sqlite3 = await sqlite3InitModule();
  const poolUtil = await sqlite3.installOpfsSAHPoolVfs();
  db = new poolUtil.OpfsSAHPoolDb('/app.sqlite3'); // names are normalised to an absolute path
  db.exec('CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)');
}

init()
  .then(() => postMessage({ type: 'ready' }))
  .catch((err) => postMessage({ type: 'init-error', message: err.message }));

Note what this code does not do: fall back to new sqlite3.oo1.DB(...) when OPFS is unavailable. That pattern appears in a lot of sample code, including the official README’s worker example, and it is a data-loss bug in disguise. The app keeps working against a transient in-memory database, the user keeps saving, and a refresh destroys everything. If persistence fails to initialize, surface the error to the UI and let the user know.

Talking to the Worker From the UI

The library still exports a promiser API for main-thread access, but the package README marks the Worker1 and Promiser1 APIs as deprecated in a notice dated 2026-04-15. They stay in the package, they get no further work, and the maintainers steer people away from them. The documented path is sqlite3InitModule plus the oo1 API inside the Worker, with a thin postMessage bridge of your own:

// worker.js (continued)
onmessage = ({ data }) => {
  const { id, sql, bind } = data;
  try {
    const rows = db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' });
    postMessage({ id, result: rows });
  } catch (err) {
    postMessage({ id, error: err.message });
  }
};
// db-client.js (main thread)
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
let nextId = 1;
const pending = new Map();

worker.onmessage = ({ data }) => {
  const entry = pending.get(data.id);
  if (!entry) return;
  pending.delete(data.id);
  data.error ? entry.reject(new Error(data.error)) : entry.resolve(data.result);
};

export function query(sql, bind = []) {
  return new Promise((resolve, reject) => {
    const id = nextId++;
    pending.set(id, { resolve, reject });
    worker.postMessage({ id, sql, bind });
  });
}

Forty lines of bridge is the whole cost, and you control the message shape.

Trip-Up One: OPFS SQLite Must Run in a Worker

OPFS-backed SQLite cannot run on the main thread, full stop. SQLite is a synchronous engine, and the synchronous file access it needs comes from FileSystemSyncAccessHandle, which the platform exposes only inside dedicated Web Workers precisely because synchronous I/O blocks whatever thread performs it.

When this constraint gets worked around rather than respected, session replay makes the failure unmistakable: the replay shows clicks and keystrokes registering while nothing repaints for the length of a query, the visual signature of synchronous OPFS I/O blocking the UI thread. Keep the engine in the Worker and the main thread never sees a query.

Trip-Up Two: The COOP/COEP Header Requirement

The default “opfs” VFS uses SharedArrayBuffer to pass messages between its synchronous front end and the async worker sitting behind it, so the server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp or the VFS will not load. For Vite, the official README gives this config, including the required optimizeDeps exclusion:

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
});

Production servers need the same two headers. But if you cannot set headers (static hosting, third-party embeds that COEP breaks), you do not need a service-worker hack: the “opfs-sahpool” VFS requires no COOP/COEP headers at all, which is why the code above uses it.

Choosing Between the Two OPFS VFSes

Pick “opfs” when multiple tabs must share one database and you control the headers; pick “opfs-sahpool” when you want maximum speed and no header requirement and can live with a single connection. SQLite’s own persistence docs rate sahpool as the quickest of the OPFS backends they cover. You will not feel the difference while saving one record. You will feel it on bulk work.

”opfs""opfs-sahpool”
COOP/COEP headersRequiredNot required
Multiple connections/tabsYes, with SQLITE_BUSY handlingNo, one at a time
PerformanceGoodFastest for batch work, per SQLite docs
RegistrationAutomatic when supportedExplicit installOpfsSAHPoolVfs()
Safari 16.4 to 16.xBroken by a WebKit sub-worker bugWorks

Multi-tab is not free even on “opfs”. Acquiring a sync access handle exclusively locks the file, and reading takes that lock too, so a second tab opening the same database hits a locking error that surfaces as SQLITE_BUSY or a generic I/O error. Handle it instead of treating it as fatal:

async function withRetry(fn, attempts = 5, delayMs = 100) {
  for (let i = 0; i < attempts; i++) {
    try {
      return fn();
    } catch (err) {
      if (!/SQLITE_BUSY/.test(String(err.message)) || i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}

Keep transactions short and statements reset, and moderate cross-tab concurrency works. On sahpool, a second tab’s installOpfsSAHPoolVfs() call fails outright: the pool takes the database lock for itself, so one connection is the ceiling. Detect that and route the second tab through the first. SQLite 3.50 added pauseVfs() and unpauseVfs() for that kind of cooperative handoff.

When Should You Choose SQLite Over IndexedDB?

Reach for SQLite over OPFS when your data is relational: joins across entities, aggregates, ad-hoc filtering, full SQL indexes, or shipping a prebuilt dataset as a single database file you import once. Those are the workloads where IndexedDB forces you to reimplement a query engine in application code.

It is overkill for key-value state, small caches, or a few hundred records. That work does not justify a Wasm binary, a Worker, and a message bridge; localStorage or plain IndexedDB is the right size.

Wrapping Up

The setup cost is real but bounded: one Worker, one message bridge, and one VFS decision that hinges on whether you need multi-tab access or header-free deployment. Start with “opfs-sahpool” for a single-document local-first app, move to “opfs” plus SQLITE_BUSY handling when tabs must share, and never let an OPFS initialization failure degrade silently into an in-memory database.

FAQs

Which browsers support SQLite Wasm with OPFS persistence?

OPFS synchronous access handles are available from Chromium 108, Firefox 111, and Safari 16.4 onward. One caveat: Safari versions below 17 carry a WebKit sub-worker bug that breaks the default 'opfs' VFS, and the SQLite docs point to 'opfs-sahpool' as the option that still works there. Safari 17 and later runs both VFSes.

Can I ship a prebuilt SQLite database file and load it into OPFS?

Yes. With the 'opfs-sahpool' VFS, fetch the .db file as an ArrayBuffer and pass it to importDb() on the PoolUtil object that installOpfsSAHPoolVfs() resolves to, then open the database normally. Pass the identical name string to both calls: importDb() stores the name exactly as you give it, while opening a database normalises it to an absolute path, so importing 'data.db' and then opening '/data.db' leaves you with an empty database. PoolUtil also provides exportFile() to extract a database for backup and getFileNames() to list what the pool holds. This suits apps that ship reference datasets as a single file.

How much data can an OPFS-backed SQLite database store?

There is no fixed limit. OPFS storage falls under browser-managed quotas that are generous but vary by browser, device, and available disk space, so check navigator.storage.estimate() at runtime rather than assuming a number. Private and incognito windows may reduce or eliminate persistence entirely, and clearing site data deletes the database along with the rest of the origin's storage.

How do I inspect the OPFS files SQLite creates during debugging?

Browser DevTools do not show OPFS contents natively. The OPFS Explorer extension for Chrome DevTools displays the origin's OPFS file hierarchy and lets you download individual files. Note that 'opfs-sahpool' stores databases inside opaque pool files under its own virtual name mapping, so the filename you passed will not appear directly; use PoolUtil's getFileNames() and exportFile() to list and extract those databases instead.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.