SQLite (Client-Side)
Run a real, full-featured SQL database inside the browser tab with sql.js (SQLite compiled to WebAssembly): joins, indexes, transactions and prepared statements on the client, plus durable persistence via IndexedDB or the Origin Private File System for genuinely offline-first apps.
What client-side SQLite actually is. For a long time the browser only offered key-value storage (localStorage), a clunky deprecated SQL API (WebSQL), and an object store (IndexedDB). None of them gives you relational querying. Client-side SQLite fixes that by taking the real, battle-tested SQLite C engine, compiling it to WebAssembly, and running it inside the page. The most popular build is sql.js; there is also the official @sqlite.org/sqlite-wasm distribution. Either way you get the exact same SQL dialect that ships on billions of phones — running in a tab.
Why you would want a database in the browser. Three scenarios dominate. (1) Offline-first apps — a field-service or note-taking app that must work with no network and sync later. (2) Rich local querying — dashboards that need JOIN, GROUP BY, window functions, and multi-column sorting over thousands of local rows, which are miserable to hand-code against IndexedDB. (3) Shipping a read-only dataset — a documentation search index or a pricing table you download once as a .sqlite file and query instantly on the client, saving round-trips.
Initializing the WASM module. sql.js is asynchronous to start because it must fetch and compile the .wasm binary. You call initSqlJs({ locateFile }), where locateFile tells it where the sql-wasm.wasm file is hosted (a CDN or your own /public path). The returned SQL object is the module; new SQL.Database() creates a fresh in-memory database, and new SQL.Database(bytes) restores one from a Uint8Array of a previously saved SQLite file.
Running SQL: run, exec, and prepared statements. db.run(sql, params) executes a statement that returns nothing (DDL, INSERT, UPDATE, DELETE). db.exec(sql, params) runs one or more statements and returns an array of result sets, each with columns and values arrays. For repeated queries, db.prepare(sql) gives you a reusable statement object you bind, step, and getAsObject over — the fastest and cleanest path for loops. Because it is real SQLite you get transactions (BEGIN/COMMIT), indexes, foreign keys, and even EXPLAIN QUERY PLAN.
Always parameterize — never concatenate. The ? placeholders with a values array are not just about SQL injection (though they defend against it exactly like a server prepared statement); they also handle quoting, escaping, and type coercion for you. String-building queries ('... WHERE name = "' + input + '"') is a bug factory even on the client, where a malicious value could still corrupt or exfiltrate the user's own local data. Treat client SQL with the same discipline as server SQL.
The persistence problem. This is the number-one gotcha: a sql.js Database lives entirely in memory, so it evaporates on reload. Persistence is your job. The classic pattern is db.export(), which serializes the whole database to a Uint8Array (the bytes of a real .sqlite file), and then writing those bytes into IndexedDB under a fixed key. On startup you read the bytes back and pass them to new SQL.Database(bytes). Because serializing the entire DB on every keystroke is wasteful, you debounce the export (e.g. save at most once per second, or on visibilitychange).
The Origin Private File System (OPFS) — the modern answer. Re-serializing the whole database is fine for small data but painful past a few megabytes. The Origin Private File System gives a page a private, high-performance, sandboxed file area. The official SQLite WASM build ships an OPFS VFS that writes pages directly to a real file, so the database is durable incrementally — no full export needed — and can be far larger. OPFS synchronous access handles require running inside a Web Worker, which is also where you want a heavy DB anyway to keep the main thread responsive.
Run it off the main thread. SQL over thousands of rows, and especially the WASM compile step, can jank your UI if done on the main thread. The production shape is: put sql.js/sqlite-wasm inside a Web Worker, post query messages to it, and post results back. Libraries such as absurd-sql and the official OPFS worker pattern formalize this. Your React components then talk to a thin async client rather than the engine directly.
sql.js vs IndexedDB — the real trade-off. IndexedDB is built in (no download), asynchronous, and great for key-value or single-object-store lookups by index. sql.js/sqlite-wasm adds a sizable WASM payload (hundreds of KB) but gives you the full relational model. Rule of thumb: reach for SQLite when your data is relational and your queries are genuinely complex (joins, aggregates, ad-hoc reporting); stick with IndexedDB for simple caches and blob storage. They are not mutually exclusive — SQLite frequently persists into IndexedDB.
Security and quota realities. Everything here lives in the user's browser, scoped to your origin and subject to storage quotas and eviction under storage pressure (call navigator.storage.persist() to request durability). Never store secrets you would not want the user to read — it is their machine and their DevTools. Treat the client DB as a cache and offline buffer, with the server remaining the source of truth you reconcile against when back online.
Syncing back to the server. Offline-first means eventual consistency. Common strategies: keep an outbox table of pending mutations that you flush when connectivity returns; stamp rows with updated_at and use last-write-wins or version vectors; or adopt CRDT-based sync. The client SQLite database is the local authority while offline, then you replay the outbox and pull server changes on reconnect.
The mental model (memorise this). Client-side SQLite is the real SQLite engine compiled to WebAssembly, running in a tab (ideally in a Worker): it gives you true relational SQL with parameterized queries, but it is in-memory by default so you must persist it yourself — export bytes to IndexedDB for small data, or use the OPFS VFS for large, incrementally-durable storage — while the server stays the source of truth you sync against.
This is literally the same SQLite engine you would embed in a JVM app via `sqlite-jdbc` or use as an in-memory H2 database in a Spring integration test — just compiled to WebAssembly and running in the browser instead of on the server. The `?` placeholders are a `PreparedStatement`: parameters are bound out-of-band, so injection and quoting are handled by the driver, not by you. The in-memory-by-default behaviour mirrors an H2 `jdbc:h2:mem:` database that you snapshot to disk with `SCRIPT TO`; exporting bytes to IndexedDB is your `mysqldump`/backup step. Running the engine in a Web Worker is the frontend version of keeping blocking JDBC off the Vert.x event loop by pushing it onto a worker/`executeBlocking` pool — same instinct, same reason: never block the single thread that keeps the UI (or the event loop) responsive.
- sql.js and @sqlite.org/sqlite-wasm are the real SQLite C engine compiled to WebAssembly, giving you true relational SQL (joins, aggregates, indexes, transactions) in the browser.
- Initialization is async because the .wasm binary must be fetched and compiled; locateFile points the loader at the hosted binary.
- db.run executes statements that return nothing; db.exec returns result sets; db.prepare gives reusable prepared statements for loops.
- Always use parameterized queries with the ? placeholder and a values array, never string concatenation, even on the client.
- A sql.js Database is in-memory by default and is lost on reload; persistence is your responsibility.
- The classic persistence pattern is db.export() to a Uint8Array, stored in IndexedDB under a fixed key, and restored via new SQL.Database(bytes) on startup.
- The Origin Private File System (OPFS) VFS makes the database incrementally durable without full re-export and supports much larger databases.
- Run the engine inside a Web Worker to keep heavy queries and the WASM compile step off the main thread; OPFS sync access handles require a Worker anyway.
- Choose SQLite over IndexedDB when data is relational and queries are complex; IndexedDB is lighter and built in for simple key-value caching.
- The client database is a cache and offline buffer subject to storage quotas and eviction; the server remains the source of truth you sync against.
Worked Code
// sql.js — full SQLite engine in the browser via WebAssembly
import initSqlJs, { type Database } from 'sql.js';
async function initDB(): Promise<Database> {
// Async: the .wasm binary must be fetched + compiled first.
const SQL = await initSqlJs({
// Point the loader at the hosted sql-wasm.wasm file (CDN or /public).
locateFile: (file) => `https://sql.js.org/dist/${file}`,
});
const db = new SQL.Database(); // fresh in-memory database
// DDL: real SQLite — indexes, constraints, defaults all supported.
db.run(`
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL,
category TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_expenses_cat ON expenses(category);
`);
// INSERT — ALWAYS parameterized (? placeholders + values array).
db.run('INSERT INTO expenses (amount, category) VALUES (?, ?)', [120, 'meals']);
db.run('INSERT INTO expenses (amount, category) VALUES (?, ?)', [40, 'transit']);
// Relational query with aggregation — the reason to use SQLite over IndexedDB.
const rows = db.exec(
'SELECT category, SUM(amount) AS total FROM expenses GROUP BY category ORDER BY total DESC'
);
console.log(rows[0]?.columns); // ['category', 'total']
console.log(rows[0]?.values); // [['meals', 120], ['transit', 40]]
return db;
}// Reuse one compiled statement across many rows.
function insertMany(db: import('sql.js').Database, items: { amount: number; category: string }[]) {
db.run('BEGIN TRANSACTION'); // batch = one durable, atomic write
const stmt = db.prepare('INSERT INTO expenses (amount, category) VALUES (?, ?)');
try {
for (const it of items) {
stmt.bind([it.amount, it.category]); // rebind params each iteration
stmt.step(); // execute
stmt.reset(); // ready for next bind
}
db.run('COMMIT');
} catch (e) {
db.run('ROLLBACK');
throw e;
} finally {
stmt.free(); // release the compiled statement
}
}
// Read back as objects (nicer than columns/values arrays).
function recentExpenses(db: import('sql.js').Database, limit: number) {
const stmt = db.prepare('SELECT * FROM expenses ORDER BY created_at DESC LIMIT ?');
stmt.bind([limit]);
const out: Record<string, unknown>[] = [];
while (stmt.step()) out.push(stmt.getAsObject());
stmt.free();
return out;
}// A sql.js DB is in-memory only. Persist by exporting the raw .sqlite bytes.
const DB_KEY = 'app-db-v1';
function idb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('sqlite-store', 1);
req.onupgradeneeded = () => req.result.createObjectStore('files');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// Debounced save: never serialize the whole DB on every keystroke.
let saveTimer: ReturnType<typeof setTimeout> | undefined;
export function scheduleSave(db: import('sql.js').Database) {
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
const bytes = db.export(); // Uint8Array = full .sqlite file
const conn = await idb();
const tx = conn.transaction('files', 'readwrite');
tx.objectStore('files').put(bytes, DB_KEY);
}, 1000);
}
// On startup: read bytes back and rehydrate, or start empty.
export async function loadBytes(): Promise<Uint8Array | undefined> {
const conn = await idb();
return new Promise((resolve) => {
const req = conn.transaction('files').objectStore('files').get(DB_KEY);
req.onsuccess = () => resolve(req.result as Uint8Array | undefined);
req.onerror = () => resolve(undefined);
});
}
// const db = bytes ? new SQL.Database(bytes) : new SQL.Database();// worker.ts — heavy DB work off the main thread, incrementally durable via OPFS.
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
async function start() {
const sqlite3 = await sqlite3InitModule();
// OPFS-backed database: writes go straight to a real sandboxed file,
// so it survives reloads WITHOUT any manual export step.
if ('opfs' in sqlite3) {
const db = new sqlite3.oo1.OpfsDb('/app.sqlite3');
db.exec('CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)');
db.exec({ sql: 'INSERT INTO notes(body) VALUES (?)', bind: ['persists across reloads'] });
const rows: unknown[] = [];
db.exec({ sql: 'SELECT * FROM notes', rowMode: 'object', resultRows: rows });
postMessage({ type: 'rows', rows });
} else {
// Fallback: transient in-memory DB (persist via export/IndexedDB instead).
postMessage({ type: 'no-opfs' });
}
}
start();
// Note: OPFS synchronous access handles REQUIRE running inside a Web Worker,
// and the page must be cross-origin isolated (COOP/COEP headers) for some builds.▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
When your data is relational and your queries are complex — joins, aggregations, GROUP BY, window functions, multi-column sorting. IndexedDB is an object store and forces you to hand-roll those operations in JavaScript, whereas sql.js lets you express them declaratively in SQL and lean on real indexes and a query planner. The cost is a sizable WASM payload, so for simple key-value or blob caching IndexedDB is lighter and built in. They also compose: SQLite frequently persists its bytes into IndexedDB.
- 1Client-side SQLite = the real SQLite C engine compiled to WebAssembly (sql.js or @sqlite.org/sqlite-wasm).
- 2Initialization is async; locateFile tells the loader where sql-wasm.wasm lives.
- 3run = no result; exec = result sets; prepare = reusable statement for loops/batches.
- 4Always parameterize with ? and a values array — never concatenate, even on the client.
- 5A sql.js DB is in-memory by default and lost on reload; persistence is your job.
- 6Persist small data via db.export() to a Uint8Array stored in IndexedDB (debounced); restore with new SQL.Database(bytes).
- 7For large data use the OPFS VFS — incrementally durable, no full export, requires a Web Worker.
- 8Run the engine in a Web Worker to keep the WASM compile and heavy queries off the main thread.
- 9Prefer SQLite for relational/complex queries; IndexedDB for simple key-value/blob caching.
- 10It is a cache/offline buffer under storage quotas — the server stays the source of truth; sync via an outbox.