You can run a full relational database, complete with ACID compliance and SQL, directly in the user’s browser using SQLite compiled to WebAssembly.
Imagine a user browsing your e-commerce site. They add items to their cart, browse product details, and even navigate between pages. All of this state—the cart contents, recently viewed items, user preferences—can be stored and managed locally in a persistent, queryable database before it ever needs to hit your backend. This isn’t just session storage; it’s a robust database.
Here’s a peek at how it works:
import { Database } from "@sqlite.org/sqlite-wasm";
async function initDb() {
const SQL = await Database.init();
const db = new SQL.Database();
// Create a table
db.exec(`
CREATE TABLE IF NOT EXISTS cart (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id TEXT NOT NULL,
quantity INTEGER NOT NULL
);
`);
// Insert an item
db.run("INSERT INTO cart (product_id, quantity) VALUES (?, ?)", "prod-abc-123", 2);
// Query the cart
const result = db.exec("SELECT * FROM cart");
console.log(result); // [ { id: 1, product_id: 'prod-abc-123', quantity: 2 } ]
return db;
}
initDb().then(db => {
// Use the db object for further operations
});
This setup involves downloading the SQLite WASM binary (around 2-3MB, gzipped) and initializing it. Once initialized, you get a familiar Database object that you can exec SQL statements against. The magic is that all this data storage and processing happens client-side, using the browser’s IndexedDB or even memory for persistence.
The core problem this solves is enabling rich, offline-first applications and reducing server load. Instead of every small interaction requiring a round trip to the server, you can manage local state efficiently. Think about:
- Offline Data Access: Users can browse, search, and even modify data when they’re offline. Changes can be synced later.
- Complex Local State Management: For applications with intricate data relationships (e.g., project management tools, note-taking apps with rich text and tagging), a relational database is far more powerful than simple key-value stores or JSON blobs.
- Performance Boost: Reducing network latency for frequent, small data operations dramatically improves perceived performance.
- Data Pre-fetching and Caching: Load large datasets once and query them locally, only fetching deltas from the server when necessary.
The Database.init() call is crucial. It handles the asynchronous loading and instantiation of the WebAssembly module. Inside the Database object, you have methods like exec for running SQL statements that don’t return results (or you don’t care about the results), and run for statements that modify data and may return a single row. For queries that return multiple rows, exec can be used, but it returns a JSON-like structure. A more idiomatic way to fetch query results is often to use db.prepare() to get a statement object, then iterate over its step() method.
async function queryCartItems(db) {
const stmt = db.prepare("SELECT product_id, quantity FROM cart WHERE quantity > 0");
const rows = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
stmt.finalize();
console.log(rows); // [ { product_id: 'prod-abc-123', quantity: 2 } ]
}
initDb().then(db => queryCartItems(db));
The prepare/step/finalize pattern is how you efficiently execute parameterized queries and iterate over results, much like you would in server-side SQLite. This is where the true power of SQL shines, allowing complex joins, aggregations, and filtering directly in the browser.
It’s worth noting that the WASM build of SQLite typically uses a single-file sqlite.db by default, which it maps to the browser’s IndexedDB for persistence. This means that even if the user closes and reopens the browser, or navigates away and back, the data remains. You can also configure it to use other storage mechanisms or even RAM-only databases for temporary, high-performance scenarios. The persistent storage is a key differentiator from localStorage or sessionStorage, offering transactional integrity and a rich query language.
The actual WASM binary itself is a compiled C program. When you call Database.init(), you’re essentially bootstrapping a virtual machine within the browser that can execute this compiled code. The SQLite library then uses browser APIs (like IndexedDB) to provide the underlying storage layer. This means you get the full ACID guarantees of SQLite—Atomicity, Consistency, Isolation, Durability—applied to the user’s local data.
When setting up, you’ll often see references to a sqlite.wasm file and potentially a sqlite.js wrapper. The sqlite.js file is usually generated by the build process and provides the JavaScript API to interact with the WASM module. The Database.init() function is the entry point that loads and prepares this WASM module for use. You can also configure the vfs (Virtual File System) option during initialization to control how SQLite interacts with the browser’s storage. The default is a good starting point, but for advanced use cases, you might want to explore custom VFS implementations.
The underlying WASM module is a highly optimized, single-threaded C implementation of SQLite. While it runs in the browser’s JavaScript event loop, it doesn’t block it for long operations because the actual database work happens within the WASM execution context. For extremely heavy computations or I/O, you might consider offloading operations to a Web Worker to keep the main thread responsive, though for typical local database operations, this is often not necessary.
The next hurdle you’ll likely encounter is managing schema migrations in a client-side database context.