← Back to Blog

SQLite in the Browser: Building a Local-First Database with OPFS

September 2026~14 min read

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:

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:

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:

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.

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:

Gotchas & Limitations

SQLite on OPFS isn't perfect. Watch out for:

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.

Related Articles