Skip to content

Zero config & no backend required

Build fast, stay local.granthdb handlesOffline storage

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.

db.js
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]',
});

granthdbSQLite in the browser

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

Why granthdb?

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

A real query planner

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.

  • Compound and multiEntry indexes
  • count() without iterating
  • bulkGet in one round trip

Off the main thread

SQL executes in a dedicated Worker, so a slow scan cannot drop a frame. localStorage is synchronous and blocks on every read.

  • No jank on large reads
  • Inline runtime for strict CSP
  • Streams results back by message

Safe across tabs

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.

  • Single-writer election
  • Failover tested by killing the writer
  • Cross-tab transactions

Encrypted at rest, honestly

Browser storage is plaintext on disk, OPFS included. A field-level AES-GCM addon seals values before they reach SQLite.

  • AES-GCM, fresh IV per value
  • Proven by grepping the raw file
  • Clear about what it cannot stop

Any framework, no adapter

A live query is already an observable and already a Svelte store, so most ecosystems need no glue at all.

  • React, Vue, Angular, Svelte
  • RxJS, Zustand, TanStack Query
  • Storage and runtime are plugins

Built on granthdb

Two apps, no backend

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.

Signals: status facets with live counts beside a filterable table of issues.

All 5,000, newest firstorderBy('updated').reverse()

5,000
issues in the table
310
lines of app code
6
indexes, one compound and one multiEntry
0
servers, build steps, framework deps
  1. Filter on one index, order by another

    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')
  2. Facets straight from the database

    Counts per status and per label, recomputed on every write instead of tallied in memory.

    where('labels').equals('perf').count()
  3. Deep paging that stays put

    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()
  4. Cross-tab, with one writer

    Open it twice and triage in either. One tab owns the database; the others route their queries to it and update.

    db.onChange(() => render())

From nothing to a working query

js
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.

What is actually different underneath

IndexedDBgranthdb
Query engineone index per query, cursor-walkedSQLite's planner
Filter on A, sort by Bfetch and sort in JSone statement
count() on 5,200 rowsiterate a cursor0.5 ms
Bulk read of 500 keys500 round tripsone IN query, 5 ms
Where it runsyour main threada dedicated Worker
Multi-tab writeslast writer winsone elected writer

Numbers are measured on one machine and vary; run bench.html on yours.

Honest limits

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.

Coming from Dexie

bash
npx granth-codemod ./src

It 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.