← Back to Lessons Lesson 48 of 48
Advanced api

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 rows

Data model

Database
└── tables: HashMap<String, Table>
    └── Table
        ├── name: String
        ├── columns: Vec<String>
        └── rows: Vec<Row>
            └── Row = HashMap<String, Value>
                └── Value: Integer(i64) | Text(String) | Null

Each 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 set

Our 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 code

SQLite 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 WhereClause values
  • Implement JOIN between two tables by matching rows on a shared column

Try It