Skip to content

6 min read

  • IndexedDB
  • SQLite
  • comparison

← All posts

IndexedDB vs SQLite WASM: which browser database should you use?

Both store structured data on the user's device. Both work offline. Both survive a reload. The difference is what happens when you ask a question about the data — and the answer decides whether your list view stays fast at 50,000 rows or falls over at 5,000.

Short version, before the detail:

  • IndexedDB is built in, costs nothing to load, and gives you one index per query.
  • SQLite in WebAssembly costs a few hundred kilobytes and gives you a query planner, real transactions and SQL.

If you never filter and sort at the same time, IndexedDB is the right answer and you can stop reading.

The comparison

IndexedDBSQLite (WASM + OPFS)
Download cost0 — it is built in~400–900 kB of WASM, cacheable
Query engineone index per query, cursor-walkeda real planner
Filter on A, sort by Bnot expressible — sort in JSone statement
Aggregatescount via cursor iterationCOUNT, SUM, AVG, MIN, MAX
Joinsnone — do it in JSyes
Transactionsyes, per object storeyes, full ACID
Runs off the main threadthe API is async, the work is not alwaysyes, in a Worker
Multi-tab writeslast writer winsone elected writer
Browser supportuniversalChrome 108+, Safari 16.4+, Firefox 111+
Typical usekey-value, simple lookupsquerying a real dataset

The one difference that actually decides it

Everything above collapses into a single question: do you filter on one field and order by another?

IndexedDB reads through a cursor, and a cursor walks exactly one index in that index's order. Filtering by status means you get results in status order. There is no second index to apply and no ORDER BY separate from the WHERE.

So this is not expressible:

js
// "open issues, newest first" — pick one: the filter or the order
store.index('status').openCursor(IDBKeyRange.only('open'));

What you write instead is a fetch-and-sort:

js
const all = await db.issues.where('status').equals('open').toArray();
all.sort((a, b) => b.updated - a.updated);
const page = all.slice(0, 25);

That is correct, and it is genuinely fine at a few hundred rows. What it does is trade a bounded cost for an unbounded one: to display 25 rows it deserialises every row that matched, across the structured clone boundary, into JavaScript objects. At 1,199 matches you build 1,199 objects and discard 1,174.

The tell is that the cost scales with how much matched, not with how much you display. A list that is instant on seed data and janky on a real account is almost always this.

One index per query, versus a query plannerIndexedDB cursoropen one indexstatuswalk 1,199 rowsinto JS objectssort in JavaScript, keep 251,174 objects discardedSQLite plannerseekstatus = openorder by updateda second indexreturn 25 rowsoff the main threadThe difference is how many rows cross into JavaScript.One returns everything that matched. The other returns the page you asked for.
A cursor is not a slow planner — it is a different mechanism, with no way to apply a second ordering.

"Just add a compound index"

A compound index on [status+updated] handles that exact query beautifully. It is the right fix when you have one such query.

It stops working when filters become optional. Add a label filter, a date range and an assignee, and a compound index answers exactly one combination of them. With n optional filters you cannot enumerate the combinations, and you are back to fetching and sorting in JS for every combination you did not anticipate.

Picking the index at query time from the predicates you were actually given is the entire job of a query planner. IndexedDB has none, so the picking has to happen when you write the schema — before you know what you will need.

What SQLite WASM actually costs

Being honest about this matters more than the feature table.

The download. Several hundred kilobytes of WebAssembly. It caches well, but the first visit pays it. Notion measured that loading it synchronously made their first page slower than the network it replaced, and shipped it fully asynchronously with the first page served from the network. Do the same: the local database should earn its cost on the second navigation, not the first.

Async everywhere. The queries run in a Worker, which is the point, but there is no synchronous read. If your current code calls localStorage.getItem inside a render path, that is a real refactor rather than a swap.

Browser support is good but not universal. Chrome 108+, Safari 16.4+, Firefox 111+, and a secure context (HTTPS or localhost). Below that you need a fallback path — granthdb degrades to IndexedDB and then to memory so Safari private browsing gets a working database instead of an exception.

One writer across tabs. This is a consequence people miss. OPFS sync access handles are exclusive, and two tabs writing one SQLite file is a corrupted file rather than a lost update. Any serious implementation elects a single writer — here is how that works and why it is not optional.

The four questions that decide it

  1. Do you filter and sort on different fields? If no, IndexedDB is fine.
  2. Does the dataset grow past a few thousand rows? Below that, fetch-and-sort in JS is genuinely fine and much less machinery.
  3. Do you need aggregates or joins? Counting or summing without pulling every row into JavaScript needs a query engine.
  4. Can you afford the WASM on first load? If your app is a landing page with a bit of state, no. If it is a workspace people keep open, easily.

Three noes and IndexedDB — or a thin wrapper over it — is the correct answer. It is built in, it costs nothing, and "built in and sufficient" beats "powerful" on every axis that matters.

The middle option

You do not have to choose between IndexedDB's API and SQLite's engine.

granthdb is SQLite compiled to WebAssembly, stored in OPFS, behind the API Dexie already established:

js
const grownups = await db.friends
  .where('age').above(18)
  .orderBy('name')
  .toArray();

Filter on one index, order by another, in one statement, in a Worker. If you have written Dexie you have already written this — the API is deliberately not the interesting part.

npm install granthdb @sqlite.org/sqlite-wasm

Or write a query in the sandbox without installing anything.