For two decades, browser-based applications stored data in cookies, localStorage, or IndexedDB. These APIs served their purpose, but they all share a fundamental limitation: they are not relational databases. You cannot JOIN across object stores. You cannot run aggregate queries. You cannot enforce foreign key constraints. Every time a web developer needed something resembling a real query, they ended up writing custom JavaScript code to filter, sort, and combine data that a SQL query could express in a single line.
That changed when SQLite came to the browser via WebAssembly. Today, full-featured SQLite databases run entirely client-side, persisted to disk through the Origin Private File System, surviving page reloads and browser restarts. This is not a toy demo. Production applications are shipping with in-browser SQLite as their primary data layer, and the implications for how we build web apps are significant.
How SQLite Runs in the Browser
SQLite is written in C. The browser runs JavaScript and WebAssembly. The bridge between them is Emscripten, the C-to-Wasm compiler. The SQLite source code compiles to a .wasm module that exposes the full SQLite API — sqlite3_open, sqlite3_exec, sqlite3_prepare, and everything else — callable from JavaScript.
The official SQLite project now maintains its own Wasm build, called sqlite3.wasm (often referenced as the "official SQLite WASM/JS build" or "sqlite-wasm"). This is significant because it means the build is maintained by the same team that maintains SQLite itself, not a third-party wrapper. The Wasm module weighs about 900KB gzipped and includes the full SQLite feature set including FTS5 (full-text search), JSON functions, and window functions.
// Initialize sqlite3 in the browser
import sqlite3InitModule from '@aspect-build/aspect-sqlite3-wasm';
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.DB('/mydb.sqlite3', 'ct');
// Create tables, insert data, run queries
db.exec("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT, body TEXT, created_at TEXT)");
db.exec("INSERT INTO notes (title, body, created_at) VALUES ('First Note', 'Hello world', datetime('now'))");
const results = db.exec({
sql: "SELECT * FROM notes WHERE title LIKE ?",
bind: ['%First%'],
returnValue: 'resultRows',
rowMode: 'object'
});
console.log(results); // [{id: 1, title: 'First Note', ...}]
The Storage Problem: OPFS to the Rescue
Running SQLite queries in memory is straightforward. The hard part is persistence. SQLite needs byte-level random access to a file on disk — it reads and writes specific pages within the database file. Browser storage APIs were not designed for this access pattern:
- localStorage: Synchronous, string-only, 5-10MB limit. Cannot store binary data efficiently.
- IndexedDB: Asynchronous, supports binary blobs, but has no random-access API. You would have to read and write the entire database file on every operation.
- Cache API: Designed for HTTP responses, not database files.
The Origin Private File System (OPFS) solved this. OPFS is a browser API that provides a sandboxed filesystem per origin with synchronous read/write access from Web Workers. The critical feature is createSyncAccessHandle(), which returns a handle with read(), write(), truncate(), and flush() methods that behave like POSIX file operations. This is exactly what SQLite's VFS (Virtual File System) layer needs.
// Under the hood: SQLite's OPFS VFS
// The sqlite3.wasm build registers an OPFS-based VFS automatically
// when running in a Web Worker with OPFS access
// From a Worker, the flow is:
// 1. Get OPFS directory handle
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('mydb.sqlite3', { create: true });
// 2. Get synchronous access handle (Worker only)
const accessHandle = await fileHandle.createSyncAccessHandle();
// 3. SQLite reads/writes through this handle
// accessHandle.read(buffer, { at: offset })
// accessHandle.write(buffer, { at: offset })
// accessHandle.flush()
The synchronous access is essential. SQLite's transaction model assumes synchronous I/O — when a COMMIT returns, the data must be on disk. OPFS's synchronous access handles provide this guarantee, but only from within a Web Worker (the main thread gets only async access, which would break SQLite's assumptions).
Why Not Just Use IndexedDB?
IndexedDB works. Millions of web apps use it. But it has friction points that compound as application complexity grows:
- No relational queries: IndexedDB is a key-value/object store. There are no JOINs, no GROUP BY, no HAVING. If you need data from two object stores combined, you write JavaScript to fetch both and merge them manually.
- Schema migrations are painful: IndexedDB version upgrades happen in
onupgradeneededcallbacks that must handle every version jump. SQLite migrations are just SQL statements:ALTER TABLE,CREATE INDEX, etc. - No full-text search: IndexedDB indexes support exact matches and range queries. Full-text search requires a separate library. SQLite's FTS5 extension handles this natively.
- Verbose API: A simple "get all items where status is active, sorted by date" requires opening a transaction, opening an object store, creating an index cursor, iterating through results, and collecting them into an array. In SQLite:
SELECT * FROM items WHERE status = 'active' ORDER BY date. - Transaction semantics: IndexedDB auto-commits transactions when they become inactive (no pending requests). This implicit behavior causes subtle bugs. SQLite transactions are explicit:
BEGIN, do work,COMMITorROLLBACK.
None of these issues are bugs in IndexedDB. It was designed as a low-level storage primitive, not a relational database. The mismatch is between what developers need (structured queries over relational data) and what IndexedDB provides (object storage with indexes).
The Local-First Architecture
In-browser SQLite enables a pattern called "local-first" software. The idea is simple: the application's primary data store is on the user's device. The server is a synchronization layer, not the source of truth. When the device is offline, the app works normally. When it reconnects, changes sync bidirectionally.
This inverts the traditional web application architecture. Instead of the browser being a thin client that fetches everything from the server, the browser becomes the primary compute and storage layer. The server's role reduces to authentication, sync, and backup.
Several projects are building on this pattern:
- cr-sqlite (Conflict-free Replicated SQLite): An extension that adds CRDTs (Conflict-free Replicated Data Types) to SQLite tables. Two clients can modify the same table offline, and when they sync, changes merge automatically without conflicts. This is built on top of SQLite's extension API and compiles to Wasm for browser use.
- ElectricSQL: A sync layer that replicates between a PostgreSQL server and SQLite clients (including browser-based ones). The developer writes standard SQL on both sides; ElectricSQL handles the replication protocol.
- PowerSync: A similar sync service that connects a backend Postgres or MongoDB to client-side SQLite. It provides a reactive API where UI components automatically re-render when the local database changes.
- wa-sqlite: A popular Wasm build of SQLite for the browser, with multiple VFS backends including OPFS, IndexedDB, and an experimental File System Access API backend.
Performance: How Fast Is It Really?
The obvious concern is performance. SQLite compiled to Wasm runs in an interpreted virtual machine inside a browser. How does it compare to native SQLite or to IndexedDB?
Benchmarks consistently show that SQLite-over-Wasm is slower than native SQLite by roughly 2-5x for CPU-bound operations (complex queries, large sorts). However, it is faster than IndexedDB for most real-world access patterns because SQLite can answer complex queries in a single call, while IndexedDB requires multiple round-trips through the browser's async event loop.
Concrete numbers from the official SQLite Wasm benchmarks:
- Inserting 10,000 rows in a single transaction: ~50ms (OPFS-backed)
- SELECT with WHERE clause over 100,000 rows: ~15ms
- JOIN across two tables with 10,000 rows each: ~8ms
- FTS5 full-text search over 50,000 documents: ~20ms
These numbers are fast enough for any interactive application. The bottleneck in practice is not query execution but I/O to OPFS, and even that is measured in low milliseconds for typical operations.
Browser Support and Limitations
The key dependency is OPFS with synchronous access handles. As of early 2026, this is supported in:
- Chrome / Edge: Full support since Chrome 102 (2022)
- Firefox: Full support since Firefox 111 (2023)
- Safari: Partial support. Safari supports OPFS but the synchronous access handle API has had implementation inconsistencies. Safari 17.4+ works reliably for most use cases.
Important limitations to be aware of:
- Workers only: Synchronous OPFS access (required for durable SQLite) only works in Web Workers, not on the main thread. Your database layer must run in a Worker and communicate with the main thread via
postMessageor a library like Comlink. - Storage quota: OPFS is subject to the browser's storage quota. Chromium browsers allocate up to 60% of total disk space per origin. Users can revoke storage permission, and browsers can evict data under storage pressure.
- No cross-tab locking: SQLite's file locking does not translate to OPFS. If two tabs open the same database, you need application-level coordination (e.g., using BroadcastChannel or SharedWorker) to prevent corruption.
- Debugging: You cannot inspect an OPFS-backed SQLite database with browser DevTools the way you can inspect IndexedDB. You need to export the database file and open it with a native SQLite tool.
When to Use It
In-browser SQLite is not the right choice for every web application. It adds complexity (Worker architecture, sync logic) and a Wasm payload to the initial load. But it is a strong fit for specific categories:
- Offline-capable apps: Note-taking, task management, writing tools, or any app where users expect to work without internet. SQLite gives you a real database that works offline, not a fragile cache layer.
- Data-heavy frontends: Dashboards, analytics tools, or explorers that load large datasets and let users slice, filter, and aggregate interactively. Running queries locally eliminates server round-trips for every interaction.
- Privacy-sensitive applications: If the data never needs to leave the user's device (personal finance trackers, health logs, private journals), local-first with SQLite means the server never sees the data.
- Developer tools: SQL playgrounds, data editors, CSV/JSON importers that need to process and query structured data in the browser.
For simple key-value storage, localStorage or IndexedDB remain simpler choices. For applications that always require server-side data (social feeds, e-commerce catalogs), a traditional API-driven architecture makes more sense. SQLite in the browser fills the gap between these extremes — applications that need a real database but do not need (or want) a server for every query.
The Bigger Picture
SQLite in the browser is part of a broader shift. WebAssembly is bringing capabilities to the browser that were previously exclusive to native applications. SQLite is just the most visible example because databases are so fundamental to application development. But the same pattern — take a mature C/C++ library, compile it to Wasm, give it access to browser storage — applies to image processing libraries, compression algorithms, cryptographic tools, and more.
The web platform is no longer a thin rendering layer for server-generated content. It is a capable application runtime, and SQLite running at near-native speed with durable persistence is one of the clearest demonstrations of that evolution.