Local-first storage
SQLite compiled to WebAssembly, kept in OPFS — the fastest storage the browser offers, with no COOP/COEP headers required.
- Works fully offline
- Megabytes, not a 5 MB cap
- Falls back when OPFS is absent
Zero config & no backend required
SQLite in the browser with a Dexie-compatible API. Real indexes and a real query planner, running off your main thread and safe across every open tab — with no server to maintain.
import { Granth } from 'granthdb'; const worker = () => new Worker('./db.worker.js');export const db = new Granth('myapp', { worker }); db.version(1).stores({ friends: '++id, name, age, *tags, [name+age]',});A Dexie-compatible API over SQLite/WASM on OPFS. Real indexes, a real query planner, off the main thread — and it runs without a Worker when it has to.
Primary benefits
SQLite compiled to WebAssembly, kept in OPFS — the fastest storage the browser offers, with no COOP/COEP headers required.
Filter on one index and order by another in a single statement. A cursor-based store walks one index per query and sorts the rest in JavaScript.
SQL executes in a dedicated Worker, so a slow scan cannot drop a frame. localStorage is synchronous and blocks on every read.
One tab is elected writer through Web Locks and every other tab routes to it. Two tabs writing one file is how browser databases corrupt.
Browser storage is plaintext on disk, OPFS included. A field-level AES-GCM addon seals values before they reach SQLite.
A live query is already an observable and already a Svelte store, so most ecosystems need no glue at all.
Built on granthdb
Both run entirely in the tab you open them in — no server, no sync service and no build step — and both are in the repository, so every claim below is something you can read the source of.
5,000 issues. Filter on one index while ordering by another, facet on an array field, page deep without the answer drifting.
1,199 open issues, newest first, in a single pass. A cursor-based store has to pick one index and walk the rest by hand.
where('status').equals('open').orderBy('updated')Counts per status and per label, recomputed on every write instead of tallied in memory.
where('labels').equals('perf').count()Page 40 of 200 returns the rows it should. Ordering is pinned to the bound index, so the answer does not drift.
.offset(975).limit(25).toArray()Open it twice and triage in either. One tab owns the database; the others route their queries to it and update.
db.onChange(() => render())import { Granth } from 'granthdb';
const db = new Granth('myapp', {
worker: () => new Worker(new URL('./db.worker.js', import.meta.url), { type: 'module' }),
});
db.version(1).stores({ friends: '++id, name, age, *tags' });
await db.friends.add({ name: 'Ada', age: 36, tags: ['math', 'engines'] });
// Filter on one index, order by ANOTHER — one SQL statement, no JS sort.
const grownups = await db.friends
.where('age').above(18)
.orderBy('name')
.toArray();If you have written Dexie, you have already written this. That is the point: the API is the same, and what changed is underneath it.
| IndexedDB | granthdb | |
|---|---|---|
| Query engine | one index per query, cursor-walked | SQLite's planner |
| Filter on A, sort by B | fetch and sort in JS | one statement |
count() on 5,200 rows | iterate a cursor | 0.5 ms |
| Bulk read of 500 keys | 500 round trips | one IN query, 5 ms |
| Where it runs | your main thread | a dedicated Worker |
| Multi-tab writes | last writer wins | one elected writer |
Numbers are measured on one machine and vary; run bench.html on yours.
A local database is a cache with opinions, not a source of truth. Before you adopt this, read Security & performance — it is explicit about what this does not give you: it is not encrypted at rest, XSS on your origin reads everything, Safari evicts script-writable storage after 7 days of no interaction, and a user can edit their own local file. Always keep a rebuild-from-server path.
npx granth-codemod ./srcIt rewrites imports, new Dexie(...) and extends Dexie, and reports rather than guesses at anything it cannot safely transform. Then import your existing IndexedDB data — schema inference included, so you are not retyping stores() by hand.