Skip to content

7 min read

  • comparison
  • testing
  • IndexedDB

← All posts

Choosing a Dexie alternative, and verifying the drop-in claim

Dexie is not the problem. Its API is the one most wrappers copy:

js
await db.friends.where('age').above(18).toArray();

is the right shape for a browser database, and years of shipped applications sit behind it.

So when you go looking for a Dexie alternative, you are usually not looking for a different API. You are looking for a different engine underneath the same one — because a cursor walks exactly one index per query, and you have started pulling the whole matching set into JavaScript to display one page of it. That trade-off has its own post; this one is about the other half of the decision.

Which reframes the evaluation. "Does it look like Dexie" is not a useful question — every wrapper looks like Dexie. The useful question is how much of Dexie is actually there, and how you would know without finding out in production.

Three things "drop-in" can mean

They are not the same claim, and they get progressively harder to make.

  1. The names exist. db.table.where().equals().toArray() resolves. Cheapest to check and cheapest to fake — a stub that returns [] passes.
  2. The names mean what they mean in Dexie. A method that exists, accepts your arguments and does something different is worse than one that is missing, because a missing method throws on the first call.
  3. Your code behaves the same. Not the library's code — yours, with your schema, your transactions and your assumptions about ordering.

Most "Dexie-compatible" claims are the first one, stated as though it were the third. When you evaluate a replacement, insist on knowing which one is on offer.

Check the first claim against the package, not the docs

A compatibility list written by hand describes what someone believed on the day they wrote it. The only list worth reading is one generated by walking the prototypes of the real dexie package, and re-generated in CI so it fails the build when it drifts.

That is what granthdb's compat-audit.mjs does. Measured against dexie 4.4.5:

ClassCoverageNot implemented
WhereClause18 / 18
Table27 / 28defineClass, deprecated in Dexie itself
Collection26 / 28clone, raw — Dexie internals
Granth21 / 26backendDB, idbdb, dynamicallyOpened, vip, unuse

92 of 100 members, with eight deliberate absences. Not a marketing number: pin the Dexie version, walk the prototype chains of both, and the number falls out. Ask any candidate replacement for that number and the script that produced it.

The eight gaps are exported as data

The waivers are not prose in a table. They ship as constants:

js
import { DEXIE_WAIVERS, DEXIE_DIVERGENCES } from 'granthdb';

DEXIE_WAIVERS holds the eight members granth deliberately does not implement, each with its reason. The point of exporting it is the invariant it makes checkable: anything missing from granth and missing from that list is a bug rather than a decision, and the audit fails the build on it.

DEXIE_DIVERGENCES is the second claim above — names that exist here but do not mean what they mean in Dexie. It is a separate list because it is the more dangerous hazard. It holds exactly one entry.

Where a name-level audit goes blind

Dexie's use() installs DBCore middleware. granth has no DBCore layer; db.use(addon) registers before/after hooks and returns a handle with dispose(). Same name, different contract — see Plugins.

For a long time use sat in the waiver list, described as having no equivalent, while db.use(addon) had been the plugin hook all along. The audit passed every single run.

It could not have failed. It compares members Dexie has against members granth lacks, so a waiver claiming something is absent that is in fact present is never looked up and never contradicted. The guard only ran in one direction, and the error was in the other one.

What caught it was a test that runs both loops: every waived name must really be absent on the live object, and every divergent name must really be present. That second loop went red on its first execution, in the MCP server's test suite.

A guard that only runs in one directionone direction100 membersdexie 4.4.5is it in granth?92 yes, 8 waiveda waiver is never re-checkedwaived-but-present is invisiblethe other directionthe 8 waiversDEXIE_WAIVERSassert absenton the live objectuse() was present all alongnow a divergence, not a gapAn audit that only looks one way cannot contradict itself.The loop asserting the waivers are still true is the one that went red.
The whole lower row is green because the check failing is the check working.

The general lesson survives the specific bug: a compatibility guard has a direction, and a guard that only runs one way will pass forever on errors in the other. Worth asking of any parity claim, including this one.

The third claim is your test suite

Names and contracts are things a library can assert about itself. Behaviour under your code is not, and no audit will ever cover it. The differential test that matters is running your existing suite against the replacement, which is why the API being identical matters — it makes that a one-line swap rather than a rewrite of the tests too.

npx granth-codemod ./src rewrites the imports and the new Dexie(...) calls, scaffolds a worker file if one is missing, and reports what it cannot safely rewrite rather than guessing. Run it with --dry first, then run your tests.

The failures you should expect are behavioural, not name-level:

  • Dexie.Promise and PSD zones are gone. Dexie's zones let you fire writes inside a transaction without awaiting them. Here they are plain promises, so always await your writes. This is the one real trap.
  • upgrade() callbacks move into the worker, as upgrades: { 2: fn }.
  • Blob and File throw rather than being silently mangled — reading their bytes is async and the value codec is not. Pass .arrayBuffer(); Files and binary data covers it.
  • Error objects store as an empty object. A round trip loses the stack and the prototype. Store a message and a code.
  • Table.hook is client-side, so a hook cannot veto a write that has already committed.

Date, NaN, Infinity, BigInt, typed arrays, Map, Set and RegExp do survive, with their constructors intact — a Float64Array does not come back a Uint8Array. The full list, with the data importer that reads your existing Dexie database's schema and rows, is on Migrating from Dexie.

When Dexie is still the right answer

Stated plainly, because the honest version of this comparison has to include it.

Dexie sits on IndexedDB, which is built into every browser and costs nothing to download. granthdb is SQLite compiled to WebAssembly: a few hundred kilobytes on first load, Chrome 108+, Safari 16.4+, Firefox 111+, and a secure context. If your dataset is a few thousand rows you read by key, or your app is mostly a landing page with some state, Dexie is simpler and the performance difference is noise. Built-in and sufficient wins that case.

The switch earns its download when you are filtering on one field and ordering by another, when you need count(), sum() or joins without pulling every matching row into JavaScript, or when two tabs writing at once has started producing disagreements. Neither database is durable — Safari evicts script-writable storage after seven days without site interaction, and roughly 0.1–0.2% of users across this whole ecosystem hit corruption anyway — so keep a rebuild-from-server path either way. Storage has the details.

Try it

npm install granthdb @sqlite.org/sqlite-wasm
  • Migrating from Dexie — the codemod, the audit table, and an importer that infers your schema from the existing database
  • Security and performance — measured numbers, and an explicit list of what this does not give you
  • Use cases — including the ones where this is the wrong tool
  • MCP server — the API surface read off live objects, for coding assistants that would otherwise pattern-match Dexie

Or run a query in the sandbox before installing anything.