Running SQLite in the Browser with Wasm

This guide answers one task: run SQLite inside a browser tab, execute real queries against it, and read the results back into JavaScript without turning every row into an unnecessary object.

Prerequisites

  • [ ] @sqlite.org/sqlite-wasm from npm, or the official distribution unpacked into your static assets.
  • [ ] A secure context. Durable storage and the fast access path both require one.
  • [ ] A worker, if you want persistence — the synchronous storage handle is worker-only.
  • [ ] Basic SQL. This page covers the wiring, not the language.

Install and initialise

The official build ships as an ES module plus a .wasm file that it fetches at runtime. As with every Wasm package, the two must stay together, and bundlers are the usual reason they do not.

npm i @sqlite.org/sqlite-wasm
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';

const sqlite3 = await sqlite3InitModule({
  print: console.log,
  printErr: console.error,
});
console.log('sqlite', sqlite3.version.libVersion);   // e.g. 3.46.1

If the initialisation hangs or throws about a missing file, the .wasm is not where the glue expects it. Most bundlers need a hint to emit it as an asset rather than trying to parse it; the same class of problem and its fixes are covered in bundling Wasm ESM with Vite.

Choose a storage backend deliberately

There are two databases you can open, and the difference is durability. The in-memory database is available everywhere and is gone on reload. The OPFS-backed database survives, and is only available in a worker on a secure context.

function openDb(sqlite3, path = '/app/main.sqlite3') {
  if ('opfs' in sqlite3) {
    return { db: new sqlite3.oo1.OpfsDb(path), durable: true };
  }
  return { db: new sqlite3.oo1.DB(':memory:', 'ct'), durable: false };
}

const { db, durable } = openDb(sqlite3);
if (!durable) console.warn('sqlite: running in memory — data will not survive a reload');

Log that warning loudly in development. A page that silently falls back to memory looks perfect until a user reloads, and the report you get back — “it lost everything” — points nowhere useful.

Which database you actually opened The OPFS-backed database requires a secure context and a worker. When either is missing the build falls back to an in-memory database that behaves identically until the page reloads, at which point everything is gone. worker + secure context? yes no OpfsDb survives reload and restart exclusive per file in-memory DB identical API, no durability fine for analysis, not for state

Create a schema and insert rows

exec runs one or more statements. For anything taking user data, bind parameters rather than interpolating — the injection risk is smaller in a local database but the correctness risk of quoting is identical.

db.exec(`
  CREATE TABLE IF NOT EXISTS notes (
    id      INTEGER PRIMARY KEY,
    title   TEXT NOT NULL,
    body    TEXT NOT NULL DEFAULT '',
    updated INTEGER NOT NULL
  );
  CREATE INDEX IF NOT EXISTS notes_updated ON notes(updated DESC);
`);

db.exec({
  sql: 'INSERT INTO notes (title, body, updated) VALUES (?, ?, ?)',
  bind: ['First note', 'Body text', Date.now()],
});

For bulk inserts, prepare once and step repeatedly inside a transaction. The difference is not marginal: inserting ten thousand rows one statement at a time can take tens of seconds, while the same rows inside one transaction with a prepared statement take well under a second.

db.transaction(() => {
  const stmt = db.prepare('INSERT INTO notes (title, body, updated) VALUES (?, ?, ?)');
  try {
    for (const n of rows) stmt.bind([n.title, n.body, n.updated]).stepReset();
  } finally {
    stmt.finalize();
  }
});

Read results without drowning in objects

The convenient API returns an array of objects, which is fine for twenty rows and wasteful for twenty thousand. exec with a callback streams rows as they are produced, letting you aggregate or render incrementally without materialising the whole set.

// convenient, allocates one object per row
const recent = db.exec({
  sql: 'SELECT id, title, updated FROM notes ORDER BY updated DESC LIMIT 50',
  rowMode: 'object',
  returnValue: 'resultRows',
});

// streaming, allocates nothing per row beyond the array you build
let count = 0;
db.exec({
  sql: 'SELECT updated FROM notes WHERE updated > ?',
  bind: [cutoff],
  rowMode: 'array',
  callback: () => { count++; },
});

The general rule is the same as for any database: push work into SQL. Counting, grouping and filtering in the engine and returning a summary beats returning rows and doing it in JavaScript, by a margin that grows with the dataset.

Expected output

A working setup logs the version, the storage mode and a query result that reflects the rows you inserted:

sqlite 3.46.1
storage: opfs (durable)
inserted 10000 rows in 412 ms
SELECT count(*) → 10000
top note → { id: 10000, title: 'Note 10000', updated: 1789459200000 }

Reload the page and run the count again. If it still reports ten thousand, persistence is genuinely working; if it reports zero, you are on the memory fallback regardless of what the configuration looked like.

Put it in a worker

Everything above runs on the main thread except the part that matters. Moving the database into a worker gives you the durable storage path and keeps query time off the render thread, at the cost of an asynchronous interface.

// db-worker.js
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.OpfsDb('/app/main.sqlite3');

self.onmessage = ({ data: { id, sql, bind } }) => {
  try {
    const rows = db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' });
    self.postMessage({ id, rows });
  } catch (e) {
    self.postMessage({ id, error: String(e) });
  }
};

Wrap the messaging in a small promise-based client on the page side so calling code reads like a normal async function. Keep the message payloads small — sending a hundred thousand rows through postMessage reintroduces exactly the cost you moved the database to avoid.

The page asks, the worker owns The page sends a statement and parameters to a worker that holds the only database connection. Query execution and storage access happen there, and only the result rows travel back. page renders, never blocks sql + bind rows worker sqlite engine in linear memory exclusive access handle OPFS file durable bytes One worker, one connection, one file — the arrangement that avoids every multi-tab locking problem before it starts.

Tuning the pragmas that matter

Three settings change performance enough to be worth setting explicitly rather than inheriting defaults.

PRAGMA journal_mode controls how writes are made durable. On OPFS the write-ahead log is not always available depending on the VFS in use, and the build will tell you what it selected. PRAGMA synchronous = NORMAL is a reasonable client-side choice: it keeps durability across application crashes while avoiding a flush on every commit.

PRAGMA cache_size is expressed in pages, or in kibibytes when negative. Raising it from the default to a few megabytes typically produces the largest single improvement for read-heavy workloads, at the cost of exactly that much tab memory.

db.exec(`
  PRAGMA synchronous = NORMAL;
  PRAGMA cache_size = -8000;      -- about 8 MB of page cache
  PRAGMA temp_store = MEMORY;
  PRAGMA foreign_keys = ON;       -- off by default, which surprises everyone
`);

foreign_keys being off unless enabled is the classic SQLite gotcha, and it behaves the same way here: your constraints are declared, parsed and completely ignored until you turn them on.

The path of one query The JavaScript API marshals the statement into the module, the engine executes it against pages supplied by the virtual file system, and rows come back as typed values. prepared statement bound parameters engine executes in linear memory VFS reads pages from the backing rows returned typed values out Prepared statements matter more here than on a server: each execution avoids a parse and a crossing. Reading rows one at a time crosses the boundary per row; fetch in batches where the result set is large. The engine is single-threaded, so a long query blocks the worker it runs in and nothing else.

Gotchas

  • OpfsDb is not a constructor. The OPFS support was not loaded — you are on the main thread, or the context is not secure. Check 'opfs' in sqlite3 before using it.
  • “database is locked” in a second tab. The access handle is exclusive by design. Elect a single owner tab rather than retrying in a loop.
  • Inserts take minutes. Each statement is its own transaction. Wrap the batch.
  • Memory climbs during a long session. Unfinalised prepared statements. Always finalize() in a finally block.
  • Dates come back as numbers. SQLite has no date type. Store epoch milliseconds as INTEGER and convert at the edges; storing ISO strings works too but sorts and compares more slowly.
  • SQLITE_BUSY on startup. A previous worker did not shut down cleanly. Close the database on beforeunload and on worker termination.

Performance note

On a laptop with an 8 MB page cache: ten thousand single-column inserts inside one transaction take roughly 400 ms; the same inserts without a transaction take over 30 s. A SELECT with an index over a hundred thousand rows returns in 2–6 ms; the same query without the index takes 40–80 ms. Materialising a hundred thousand rows as JavaScript objects costs about 180 ms on its own, which is usually more than the query — the reason to aggregate in SQL.

Frequently Asked Questions

Is this the same SQLite as everywhere else? Yes — the official build of the same C source, compiled with Emscripten. File format, SQL dialect and behaviour match, so a database file created here opens in any SQLite tool.

Can I load an existing .sqlite3 file? Yes. Fetch the bytes and either write them into OPFS or use the deserialise API to open them in memory. Shipping a prebuilt database as a static asset is much faster than importing rows on first run.

What about sql.js? It is the older Emscripten build, still widely used, memory-only by default and without the OPFS path. For new work the official build is the better starting point; sql.js remains fine for pure in-memory analysis.

How do I back up the database? Export the file and hand it to the user as a download. With OPFS you can read the file’s bytes directly; with an in-memory database, the serialise API produces the same byte sequence. Either way the result is an ordinary SQLite file, which makes support and debugging enormously easier.

Does the database work offline? Entirely — that is much of the point. Once the engine and the file are cached, nothing in this page needs the network, which makes it a natural foundation for an offline-first application that syncs when a connection returns.

← Back to Databases & Persistent Storage in Wasm