All articles
PWAIndexedDBJavaScriptWeb Storage

IndexedDB for App Developers: A Practical Primer

1 July 20265 min readAD IT Consulting

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:

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.

More on this topic

Building a web app?

We write from experience — if you need help with any of this in your own environment, get in touch.

Talk to us
Work with us

Ready to build something worth using?

Tell us what you're working on. We respond within one business day.

Start a conversation