The Dream: A Real Database in Your Browser
For years, browser-based storage has been a compromise. localStorage gives you 5MB of key-value pairs. IndexedDB gives you more space but with an API that makes developers cry. Cache API is great for network responses but terrible for structured data.
What developers have always wanted is a real relational database — with SQL queries, indexes, transactions, and ACID guarantees — running entirely in the browser. No server. No sync conflicts. No latency.
With SQLite compiled to WebAssembly and the new OPFS (Origin Private File System) API, that dream is now a reality.
What is OPFS?
The Origin Private File System is part of the File System Access API. It gives each origin (website) its own private, sandboxed file system. Unlike localStorage or IndexedDB, OPFS provides actual file I/O — you can create, read, write, and seek within files using a familiar file-handle API.
// Get the root directory of the origin's private file system
const root = await navigator.storage.getDirectory();
// Create a file
const fileHandle = await root.getFileHandle('mydata.db', { create: true });
// Get a writable stream
const writable = await fileHandle.createWritable();
await writable.write(someData);
await writable.close();
// Read it back
const file = await fileHandle.getFile();
const contents = await file.arrayBuffer();
OPFS has two modes:
- Main thread access — uses
FileSystemFileHandlewith async reads/writes. Convenient but slower. - Synchronous access (in Workers) — uses
createSyncAccessHandle()for synchronous I/O. This is the fast path, and it's exactly what SQLite needs.
SQLite + WebAssembly
The official SQLite WebAssembly project compiles the full SQLite engine to WASM. Combined with OPFS, it provides a persistent SQLite database that survives page refreshes and browser restarts:
// Using the official SQLite WASM build
import sqlite3InitModule from '@aspect-build/sqlite-wasm';
const sqlite3 = await sqlite3InitModule();
// Open a database backed by OPFS
const db = new sqlite3.oo1.OpfsDb('/mydata.db');
// Create a table
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
tags TEXT
)
`);
// Insert data with parameterized queries (prevent SQL injection)
db.exec('INSERT INTO notes (title, content, tags) VALUES (?, ?, ?)', {
bind: ['My First Note', 'Hello from the browser!', 'test,demo']
});
// Query with results
const rows = db.exec('SELECT * FROM notes WHERE tags LIKE ?', {
bind: ['%demo%'],
returnValue: 'resultRows'
});
console.log(rows);
This is a real SQLite database. You get B-tree indexes, WAL journaling, full-text search (FTS5), JSON functions, recursive CTEs — everything SQLite offers. The only difference: it runs in your browser.
OPFS Performance
OPFS with synchronous access handles (in Workers) is surprisingly fast. Benchmarks on a mid-range laptop:
- Sequential writes: ~200 MB/s
- Random reads: ~150 MB/s
- SQLite INSERT (single row): ~50,000 rows/second
- SQLite SELECT (indexed): ~200,000 rows/second
For comparison, IndexedDB typically manages 5,000-10,000 writes/second for simple key-value operations. SQLite on OPFS is 5-10x faster for structured data workloads, with the added benefit of SQL queries and indexes.
IndexedDB vs SQLite: When to Use Which?
IndexedDB is still the right choice for many use cases. Here's a decision framework:
- Use IndexedDB when: simple key-value storage, caching API responses, storing blobs/files, you need broad browser support including older browsers
- Use SQLite on OPFS when: relational data with joins, complex queries with filtering/sorting, full-text search, you need indexes for performance, data migration from existing SQLite databases
For browser extensions specifically, SQLite on OPFS is compelling because it lets you reuse the same database schema as a server-side application. Tools like LocalBox2FA keep TOTP secrets local by default — a relational database is a natural fit for organizing accounts, groups, and metadata.
Full-Text Search in the Browser
One of SQLite's most powerful features is FTS5 — a full-text search engine that runs entirely client-side:
// Create a full-text search virtual table
db.exec(`
CREATE VIRTUAL TABLE notes_fts USING fts5(
title, content, tags,
content='notes',
content_rowid='id'
)
`);
// Populate the FTS index
db.exec(`
INSERT INTO notes_fts (rowid, title, content, tags)
SELECT id, title, content, tags FROM notes
`);
// Search with ranking
const results = db.exec(`
SELECT notes.*, rank
FROM notes_fts
JOIN notes ON notes.id = notes_fts.rowid
WHERE notes_fts MATCH 'browser privacy'
ORDER BY rank
`, { returnValue: 'resultRows' });
This gives you Google-style search over your local data, with ranking, phrase matching, boolean operators, and prefix queries — all running in the browser without a search server.
Sync Patterns for Multi-Device
A local-only database has one obvious limitation: your data is stuck on one device. The solution is sync. There are several patterns:
CRDT-Based Sync
Conflict-free Replicated Data Types (CRDTs) allow multiple devices to edit the same data independently and merge without conflicts. Libraries like Yjs and Automerge provide CRDT implementations that can sit on top of SQLite.
Change-Data-Capture
Track changes in SQLite using triggers, then sync only the deltas to a server. This is the pattern used by Electric SQL and PowerSync.
Import/Export
The simplest approach: export the entire database as a file, transfer it, and import on another device. SQLite's file format is designed for this — it's a single portable file.
// Export the database as a downloadable file
const data = db.export(); // Returns Uint8Array
const blob = new Blob([data], { type: 'application/x-sqlite3' });
const url = URL.createObjectURL(blob);
// Create download link
// Import: just open the file as a database
const importedDb = new sqlite3.oo1.OpfsDb('/imported.db');
importedDb.exec(new Uint8Array(fileArrayBuffer));
Browser Extension Storage Revolution
Browser extensions have traditionally been limited to chrome.storage.local (with a 10MB quota) and IndexedDB. OPFS changes the game: extensions can now have gigabyte-scale persistent storage with a real database engine.
This is transformative for data-heavy extensions. Consider:
- Note-taking extensions (like VKT Note) — store thousands of notes with full-text search, tags, and cross-references
- Form management extensions (like VKT Form) — save form snapshots with metadata, search by site/date/content
- Bookmark/Link managers (like LinkHarvest) — organize thousands of links with relational queries
- 2FA authenticators (like LocalBox2FA) — store TOTP secrets with proper relational structure
Gotchas & Limitations
SQLite on OPFS isn't perfect. Watch out for:
- Worker-only synchronous access — the fast synchronous API only works in Workers (including Service Workers and Offscreen Documents). On the main thread, you're limited to async access, which is slower.
- Storage eviction — browsers can evict OPFS data if storage pressure is high. Call
navigator.storage.persist()to request persistent storage (requires user permission on some browsers). - No cross-origin access — OPFS data is sandboxed per origin. You can't share a database between your extension popup and a web page (use message passing instead).
- WASM file size — the SQLite WASM binary is ~800KB-1.2MB depending on the build. Acceptable for extensions but worth noting for bundle size optimization.
- Browser support — OPFS synchronous access requires Chrome 102+, Firefox 111+, Safari 15.2+. Older browsers get async-only access or no OPFS at all.
Getting Started
Here's the minimal setup to get SQLite running with OPFS in your project:
// 1. Install the SQLite WASM package
// npm install @aspect-build/sqlite-wasm
// 2. In a Worker (or Service Worker)
import sqlite3InitModule from '@aspect-build/sqlite-wasm';
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.OpfsDb('/app-data.db');
// 3. Use it like any SQLite database
db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)');
db.exec('INSERT OR REPLACE INTO kv VALUES (?, ?)', { bind: ['theme', 'dark'] });
const theme = db.exec('SELECT value FROM kv WHERE key = ?', {
bind: ['theme'],
returnValue: 'resultRows'
});
console.log(theme[0][0]); // 'dark'
That's it. You now have a persistent, relational database running entirely in the browser. No server. No sync. No compromises.
Local-First is the Future
The local-first movement is gaining momentum. Users are tired of cloud dependencies, subscription fatigue, and data breaches. Browser extensions have always been local-first by nature — they run on your machine, process your data, and (in the best cases) never phone home.
SQLite on OPFS gives local-first applications the storage engine they deserve. It's fast, it's persistent, it's SQL. Combined with WebAssembly, WebGPU, and the modern browser platform, we can build applications that are more private, more reliable, and often faster than their cloud-based counterparts.
The browser isn't just a window to the web anymore. It's a computing platform. And with tools like SQLite on OPFS, it's becoming a pretty good one.