SQLite in the Browser via Wasm
Introduction
Running a full SQL database in the browser sounds impossible, but SQLite compiled to WebAssembly makes it a reality. This lesson builds a simplified in-memory SQL engine in pure Rust to understand the core concepts, then discusses how real SQLite runs in Wasm.
Why SQLite in the browser?
Traditional web apps send every data query to a server:
Traditional:
┌──────────┐ HTTP request ┌──────────┐ SQL query ┌──────────┐
│ Browser │──────────────>│ Server │────────────>│ Database │
│ │<──────────────│ │<────────────│ │
│ │ HTTP response│ │ result set │ │
└──────────┘ └──────────┘ └──────────┘
Latency: 50-200ms per query
With SQLite in Wasm:
┌──────────────────────────────┐
│ Browser │
│ ┌──────────┐ ┌───────────┐│
│ │ App (JS) │──│SQLite Wasm││
│ │ │ │(in-memory) ││
│ └──────────┘ └───────────┘│
│ Latency: <1ms per query │
└──────────────────────────────┘Use cases:
- Offline-first apps -- full SQL capability without network
- Local data processing -- filter/sort/aggregate large datasets on the client
- Privacy-sensitive apps -- data never leaves the device
- Prototyping -- no backend needed during development
How our SQL engine works
Our simplified engine implements four core operations:
SQL Operation │ Method │ Description
─────────────────┼─────────────────┼──────────────────────────
CREATE TABLE │ create_table() │ Define columns for a table
INSERT INTO │ insert() │ Add a row with values
SELECT ... WHERE │ select() │ Read rows, filter, project
UPDATE ... WHERE │ update() │ Modify matching rows
DELETE ... WHERE │ delete() │ Remove matching rowsData model
Database
└── tables: HashMap<String, Table>
└── Table
├── name: String
├── columns: Vec<String>
└── rows: Vec<Row>
└── Row = HashMap<String, Value>
└── Value: Integer(i64) | Text(String) | NullEach row is a HashMap<String, Value>, which makes column access O(1) but uses more memory than a columnar layout. Production databases use more efficient representations.
Query execution pipeline
Real SQL databases process queries through multiple stages:
SQL String
│
▼
┌──────────┐
│ Lexer │ Break into tokens: SELECT, *, FROM, users, WHERE, ...
└────┬─────┘
▼
┌──────────┐
│ Parser │ Build Abstract Syntax Tree (AST)
└────┬─────┘
▼
┌──────────┐
│ Planner │ Choose execution strategy (which index to use?)
└────┬─────┘
▼
┌──────────┐
│ Executor │ Scan table, apply filters, project columns
└────┬─────┘
▼
Result setOur simplified engine skips the lexer/parser and calls methods directly, but the execution logic (scan, filter, project) is the same.
Indexing: making queries fast
Without an index, every query must scan all rows (full table scan). An index is a sorted data structure that enables fast lookups:
Full table scan (no index):
SELECT * FROM users WHERE age = 30
→ Check row 1: age=30? Yes ✓
→ Check row 2: age=25? No
→ Check row 3: age=35? No
→ Check row 4: age=28? No
Time: O(n) — must check every row
With B-tree index on age:
[28]
/ \
[25] [30, 35]
→ Navigate tree: 30 > 28 → go right → found 30
Time: O(log n) — skip most rows| Rows | Full scan | B-tree index |
|---|---|---|
| 100 | 100 checks | ~7 checks |
| 10,000 | 10,000 checks | ~14 checks |
| 1,000,000 | 1,000,000 checks | ~20 checks |
Transactions: ACID guarantees
SQLite provides ACID transactions even in Wasm:
BEGIN TRANSACTION;
INSERT INTO accounts (id, balance) VALUES (1, 1000);
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Either ALL three operations succeed, or NONE of them do- Atomicity -- all operations succeed or all are rolled back
- Consistency -- database constraints are always maintained
- Isolation -- concurrent transactions do not interfere
- Durability -- committed data survives crashes (in Wasm: persisted to storage)
Persisting data: IndexedDB and OPFS
Wasm SQLite databases are in-memory by default. To persist data across page reloads, you need a storage backend:
IndexedDB backend
┌──────────┐ ┌──────────┐ ┌────────────┐
│ SQLite │ VFS │ JS shim │ │ IndexedDB │
│ (Wasm) │─────>│ (adapter) │─────>│ (browser) │
│ │ │ │ │ │
└──────────┘ └──────────┘ └────────────┘SQLite uses a Virtual File System (VFS) layer. In Wasm, this VFS is implemented in JavaScript to read/write pages to IndexedDB.
OPFS backend (modern browsers)
The Origin Private File System (OPFS) provides true file system access:
┌──────────┐ ┌──────────┐
│ SQLite │ VFS │ OPFS │
│ (Wasm) │─────>│ (native) │
│ │ │ file I/O │
└──────────┘ └──────────┘OPFS is faster than IndexedDB because it supports synchronous reads in Web Workers, which matches SQLite's synchronous I/O model.
| Storage backend | Read speed | Write speed | Compatibility |
|---|---|---|---|
| In-memory | Fastest | Fastest | All browsers |
| IndexedDB VFS | ~2x slower | ~5x slower | All browsers |
| OPFS VFS | ~1.2x slower | ~1.5x slower | Chrome 102+, Firefox 111+ |
Real SQLite in Wasm: sql.js and alternatives
sql.js -- The most popular SQLite-in-the-browser solution. It compiles SQLite's C code to Wasm using Emscripten:
const SQL = await initSqlJs();
const db = new SQL.Database();
db.run("CREATE TABLE users (id INTEGER, name TEXT)");
db.run("INSERT INTO users VALUES (1, 'Alice')");
const results = db.exec("SELECT * FROM users");Rust alternatives:
| Project | Approach | Status |
|---|---|---|
| sql.js | C → Emscripten → Wasm | Mature, widely used |
| rusqlite + wasm | Rust bindings to SQLite C | Works with wasm32 target |
| gluesql | Pure Rust SQL engine | Native Wasm, no C dependency |
| limbo | Pure Rust SQLite compatible | Newer, Wasm-first design |
Performance: SQLite Wasm vs IndexedDB
Benchmarking 10,000 row operations:
┌──────────────────────┬──────────┬──────────┬──────────┐
│ Operation │ SQLite │ IndexedDB│ Ratio │
│ │ (Wasm) │ (native) │ │
├──────────────────────┼──────────┼──────────┼──────────┤
│ INSERT (single) │ 0.02ms │ 0.5ms │ 25x │
│ INSERT (bulk 10K) │ 15ms │ 450ms │ 30x │
│ SELECT (full scan) │ 2ms │ 35ms │ 17x │
│ SELECT (indexed) │ 0.05ms │ 0.8ms │ 16x │
│ UPDATE (single) │ 0.03ms │ 1.2ms │ 40x │
│ Complex JOIN │ 8ms │ N/A* │ -- │
│ Aggregate (COUNT) │ 1ms │ 30ms │ 30x │
└──────────────────────┴──────────┴──────────┴──────────┘
* IndexedDB has no JOIN support — requires manual JS codeSQLite in Wasm is dramatically faster than IndexedDB for most operations, and provides full SQL support including JOINs, subqueries, and window functions.
When to use SQLite in Wasm
| Scenario | Recommendation |
|---|---|
| Simple key-value storage | Use localStorage or IndexedDB |
| Complex queries with JOINs | SQLite in Wasm |
| Offline-first with sync | SQLite + custom sync protocol |
| Large datasets (>10MB) | SQLite in Wasm (better performance) |
| Form data / preferences | IndexedDB is sufficient |
| Full-text search | SQLite FTS5 extension |
Try it
Extend the SQL engine to:
- Add ORDER BY support that sorts results by a column (ascending or descending)
- Implement COUNT, SUM, AVG aggregate functions
- Add a simple B-tree index on a column for O(log n) lookups
- Support AND/OR in WHERE clauses by composing multiple
WhereClausevalues - Implement JOIN between two tables by matching rows on a shared column