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-wasmfrom 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.
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.
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.
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 sqlite3before 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 afinallyblock. - Dates come back as numbers. SQLite has no date type. Store epoch milliseconds as
INTEGERand convert at the edges; storing ISO strings works too but sorts and compares more slowly. SQLITE_BUSYon startup. A previous worker did not shut down cleanly. Close the database onbeforeunloadand 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.
Related
- Persisting a Wasm database to OPFS — the durability story in full.
- Syncing a browser database with a server — keeping local and remote in agreement.
- Loading Wasm in a Web Worker with ESM — the worker plumbing.
← Back to Databases & Persistent Storage in Wasm