I maintain a browser database, so treat everything here with the suspicion that deserves. I have tried to write the post I would want to read before adopting one — which means the section on when not to is longer than the section on when to.
The short version: this is a decision about queries, not about storage. Almost everyone asking "where should I put this data" actually needs a smaller answer than they think.
Start from the symptom, not the technology
You do not need a database because local-first is interesting. You need one when you are already seeing something specific.
Most "we need local storage" conversations end in the top three rows. Only the last one is a database problem.
1. You JSON.parse a growing blob on every page load
The classic. One localStorage key holds an array, you parse it on boot, filter it in memory, and it grew.
localStorage is synchronous, so this blocks the main thread and you cannot
move it off the critical path. Invisible at 5 KB, a dropped frame at 5 MB, every
navigation.
2. You are sorting a list in JavaScript because your store cannot
const all = await store.where('status').equals('open').toArray();
all.sort((a, b) => b.updated - a.updated);
That is IndexedDB's shape: one index per query, so filtering on one field and ordering by another means fetching everything that matched and sorting it yourself. The cost scales with what matched, not with what you display. The longer version of this is its own post.
3. Two tabs of your app disagree
Anything with a real write path hits this. Whether it corrupts data or merely shows stale data depends on your storage, but "last writer wins across tabs" is rarely what anyone actually wanted. Fixing it properly means a single elected writer, which is genuinely fiddly.
4. Storage reads show up in a profile
Not "feels slow" — shows up. If you have not profiled, that is the next step, not this decision.
When adding one makes things worse
I would rather lose the adoption than have someone reach the third month and find out.
The dataset is small. A few hundred records read once does not justify a WASM download on first load. Notion found that loading SQLite synchronously made their first page slower than the network it replaced. If your data never grows past what fits comfortably in memory, memory is the right store.
The data changes constantly for everyone. A live ticker, a presence list, a chat that is always connected. There is nothing to cache; you would be adding a database to display a websocket.
You need server-enforced authorisation per row. A client-side database cannot enforce it, and the user can edit their own file. Enforce it server side and treat the local copy as a replica of what they were already allowed to see.
You need users to see each other's edits. That is a sync engine — conflict resolution, causality, merge semantics. A local database is one component of that, and the smaller one. If multiplayer is the requirement, start there, not here.
The three things nobody plans for
Whatever you pick, these bite:
Browser storage is evictable. Safari clears script-writable storage after seven days without interaction, and iOS is the most aggressive. Ask for persistence, and then design as if you were refused:
await navigator.storage.persist();
const { quota, usage } = await navigator.storage.estimate();
If your app cannot rebuild its local state from your server, you do not have a cache — you have a single copy of the user's data in the least durable place available.
Ship a reset. Corruption happens in the field at roughly 0.1–0.2% of users across this whole ecosystem, from browser crashes and third-party cleanup tools. A "reset local data" button turns a support ticket into a click. You will need it before you think you will.
It is not encrypted. OPFS, IndexedDB and localStorage all sit on disk in plaintext. Field-level encryption helps against device theft and disk forensics. It does not help against XSS — script on your origin calls your decrypt path exactly as easily as it reads localStorage — and anyone telling you otherwise is selling something.
And the one that is not about storage at all: session tokens do not belong in
any of these. Not localStorage, not IndexedDB, not a database, encrypted or
not. They belong in an httpOnly cookie that JavaScript cannot read. Moving a
token from one JS-readable store to another is motion, not progress.
The measurement that actually decides it
If you adopt one, this is the part to get right, because the failure is counter-intuitive.
Notion measured roughly 20% faster navigation overall — and 28% in Australia, 31% in China, 33% in India, where the network was the bottleneck. But on slow devices their p95 got worse, because reading from a cheap disk can lose to a fast connection.
A local cache is not automatically faster. It is faster on average, and the average is exactly where that regression hides.
So: measure p95 and p99, test on a slow device rather than your laptop, and consider not choosing at all — race the local read against the network and take whichever answers first:
const rows = await Promise.any([
local().then((r) => (r.length ? r : Promise.reject())),
fetch(url).then((r) => r.json()),
]);
Promise.any, not race, so an empty local result cannot win by returning
nothing.
If you land on yes
granthdb is the one I build: SQLite compiled to WebAssembly, in OPFS, behind a Dexie-compatible API, with one elected writer across tabs and no COOP/COEP headers required.
npm install granthdb @sqlite.org/sqlite-wasm
- Package — granthdb on npm
- Documentation — granthlabs.github.io
- Source — granthlabs/granth
- Which use case is yours — start from the symptom
- The limits, stated plainly — security and performance
There is a sandbox if you would rather try a query than install anything, and a codemod if you are on Dexie already.
But please re-read the second section first. The best outcome of this post is
that some of you close the tab and go back to localStorage, because that was
the right answer and it was already working.
The project write-up is at /case-studies/granthdb-browser-sqlite.