Most developers reach for localStorage the first time they need to persist something client-side. It's simple, synchronous, and works everywhere. But it has hard limits: 5–10 MB depending on the browser, strings only, no transactions, no queries. The moment your app needs to store more than a handful of settings, localStorage starts fighting you.
IndexedDB is the browser's real database. It's been available in every major browser for years, but its API is notoriously awkward — enough to put people off entirely. This guide covers what you actually need to know to use it well.
What IndexedDB is (and isn't)
IndexedDB is a document database that runs natively in the browser. It stores JavaScript objects (not just strings), supports indexes for efficient querying, handles concurrent reads and writes with transactions, and has no practical storage limit in modern browsers (quota is typically 60% of available disk space).
What it isn't: a relational database. There are no JOINs, no foreign keys, no SQL. If you need those things, you're looking at a WebAssembly SQLite port — a different topic entirely.
The raw API vs wrapper libraries
The native IndexedDB API is callback-based and genuinely unpleasant to use directly. Opening a database looks like this:
const request = indexedDB.open("mydb", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore("bookmarks", { keyPath: "id", autoIncrement: true });
};
request.onsuccess = (event) => {
const db = event.target.result;
// Now use db...
};
request.onerror = (event) => {
console.error("Failed to open DB", event.target.error);
};
Every operation follows this pattern. It works, but it's noise.
The idb library by Jake Archibald wraps the native API in promises without adding any abstractions on top. It's the right default:
import { openDB } from "idb";
const db = await openDB("mydb", 1, {
upgrade(db) {
db.createObjectStore("bookmarks", { keyPath: "id", autoIncrement: true });
},
});
That's it. Everything else — get, put, delete, getAll, transactions — works the same way but returns promises. Use idb unless you have a specific reason not to.
Schema design and versioning
The version number you pass to openDB is critical. Every time you change your schema — adding a store, adding an index, changing a key path — you must increment the version. The upgrade function receives both the old and new version numbers, so you can migrate data incrementally:
const db = await openDB("mydb", 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore("bookmarks", { keyPath: "id", autoIncrement: true });
}
if (oldVersion < 2) {
// Added in v2 — index for fast tag lookups
const store = db.transaction("bookmarks").objectStore("bookmarks");
store.createIndex("by-tag", "tags", { multiEntry: true });
}
},
});
Write migrations this way from day one. Trying to retrofit versioning later is painful.
Transactions and why they matter
Every read and write in IndexedDB happens inside a transaction. Transactions are either readonly or readwrite. Using the right mode matters for performance — the browser can run multiple readonly transactions concurrently, but readwrite transactions are serialised.
// Good: readonly for queries
const tx = db.transaction("bookmarks", "readonly");
const all = await tx.objectStore("bookmarks").getAll();
// Required: readwrite for mutations
const tx = db.transaction("bookmarks", "readwrite");
await tx.objectStore("bookmarks").put({ id: 1, title: "Updated", url: "https://example.com" });
await tx.done;
Always await tx.done after a readwrite transaction. Skipping this means you won't catch write errors.
The origin-scoping gotcha
IndexedDB is scoped to an origin — the combination of protocol, hostname, and port. If you change your domain, the database doesn't follow. Users who stored data at app.example.com will not see it at example.com, even if you set up a redirect.
We learned this the hard way during a product rename. The only safe recovery is: detect the old origin on the new origin (impossible directly), or set up the old origin to export data and redirect users to a migration page before switching.
If you're still deciding on a domain: get it right before you have users, not after.
When to use IndexedDB
Use it when you need any of these:
- Structured data with multiple fields you want to query or index
- More than a few hundred kilobytes of data
- Data that should survive page reloads without a server round-trip
- Offline capability — IndexedDB is the right partner for a Service Worker
Stick with localStorage for: simple key-value settings, flags, small strings, tokens. It's faster for those cases and simpler to reason about.
A note on privacy
IndexedDB is local storage — data stored there never leaves the device unless you explicitly send it somewhere. For apps where user privacy matters, this is a feature, not a limitation. There's no server to breach, no database to leak. The only thing to protect is the browser itself.
That's the architecture we use for our own products. It's not the right model for everything, but for personal productivity tools, it's hard to argue against.
We build local-first web apps and PWAs at AD IT Consulting. If you're thinking about this kind of architecture for your product, get in touch.