IndexedDB
When localStorage runs out. The object-store model, in plain words.
localStorage runs out somewhere around 5 megabytes. Once you're storing a few thousand cached articles, a sketch the user is working on, or a small offline-first dataset, you need a real database in the browser. IndexedDB is that database. It's the only built-in option for "more than a small amount of data" — every offline-first library you've heard of (Dexie, idb-keyval, RxDB) is a thin wrapper around it.
Two things put people off IndexedDB. First, the API was designed in 2010 and uses event callbacks instead of promises. Second, the docs lean on database vocabulary (object stores, transactions, key paths) that sounds heavier than it is. Both are fixable. The model itself is simple — once you see the shape, the awkward syntax stops mattering.
The object-store model
Forget tables and SQL. IndexedDB is a key-value store for objects, with one twist: each object store (think: a named bucket) can have indexes (think: extra lookup tables) over fields of the objects you put in.
A database has one or many object stores. An object store holds objects, each identified by a key. The key can be assigned automatically (autoincrement) or pulled from a field on the object itself.
Three vocabulary words and you're done with the model:
- Database — a top-level named container, scoped to your origin. You name it (
"app"). - Object store — a bucket inside the database (
"articles","drafts"). Each store holds objects keyed by some value. - Index — a lookup table inside a store (
"by-author"). Lets you find objects by a non-key field without scanning the whole store.
Operations always happen inside a transaction — a short-lived, atomic unit of work that wraps one or more reads/writes. Transactions auto-complete; you don't commit them.
Opening a database
indexedDB.open(name, version) returns a request. The first time it runs (or whenever the version increases), it fires onupgradeneeded — that's your one chance to create stores and indexes. Schema changes happen only in this callback.
const request = indexedDB.open("app", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
/* Only runs the first time, or when version bumps. */
const articles = db.createObjectStore("articles", { keyPath: "id" });
articles.createIndex("by-author", "author", { unique: false });
};
request.onsuccess = (event) => {
const db = event.target.result;
/* Now you can read and write. */
};
request.onerror = (event) => {
console.error("open failed", event.target.error);
};keyPath: "id" means "the object's id field is the key". So db.put({ id: 7, title: "..." }) stores under key 7 automatically. The alternative is autoIncrement: true — the store assigns keys for you.
The version starts at 1 and you bump it whenever you change the schema. The next time the user opens the page, onupgradeneeded fires again and you migrate.
onupgradeneeded is the only place you can create or drop stores or indexes. Don't try to do it in onsuccess — by then the database is open in read-write mode for your data, but the schema is already locked.
Reading and writing
Once the database is open, every read or write happens inside a transaction. You name the stores you'll touch and whether you need write access.
const tx = db.transaction("articles", "readwrite");
const store = tx.objectStore("articles");
store.put({ id: 1, title: "Promises", author: "ada" });
store.put({ id: 2, title: "Streams", author: "ada" });
tx.oncomplete = () => console.log("written");
tx.onerror = () => console.error(tx.error);A few things to notice:
db.transaction(name, mode)—modeis"readonly"(default) or"readwrite".store.puteither inserts or replaces.store.addonly inserts (errors if the key already exists).store.delete(key)removes one.store.get(key)reads one.- The transaction commits automatically once the JavaScript stack is empty and no more pending requests are queued. There's no
tx.commit()call to remember in the common case. tx.oncompletefires when everything has written successfully;tx.onerrorfires if anything inside the transaction failed.
Reading is the same shape:
const tx = db.transaction("articles", "readonly");
const req = tx.objectStore("articles").get(1);
req.onsuccess = () => console.log(req.result); /* the article object */Indexes for finding things
Without an index, looking up "every article by author 'ada'" means iterating over the whole store. With an index, the database has already organized those entries by author, and the lookup is fast.
const tx = db.transaction("articles", "readonly");
const index = tx.objectStore("articles").index("by-author");
const req = index.getAll("ada");
req.onsuccess = () => console.log(req.result);
/* every article whose author === "ada" */You created the by-author index in onupgradeneeded. Each time you put an article, IndexedDB updates the index for you. From the read side, index.get(value) returns the first match, index.getAll(value) returns every match.
unique: true on an index forbids two objects from sharing the same indexed value (useful for, say, an email field that should be unique).
id. What do you create?Promises around the API
The native API uses event callbacks, which gets noisy fast. Two clean patterns:
Pattern 1: a tiny promise wrapper around any request.
function promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getArticle(db, id) {
const tx = db.transaction("articles", "readonly");
return promisify(tx.objectStore("articles").get(id));
}The wrapper turns an IndexedDB request into a promise. From the outside, callers await like any other async API — no callbacks visible.
Pattern 2: use a tiny library. In real codebases you'll usually pull in idb or idb-keyval to skip writing the wrapper yourself. Both are a few KB and cover the common shape exactly. The point of seeing the raw API is so the library doesn't feel magic — you know what's underneath.
Transactions auto-complete when the JS stack drains. If you await something unrelated in the middle of a transaction (a fetch, a setTimeout), the transaction can complete before you queue the next operation. Keep transactions short — one logical unit of work per transaction.
await fetch(...) for unrelated data, then try to write another record. The second write throws "transaction is finished". Why?Try it yourself
indexedDB.open("app", 2) do when the user already has version 1 open?email?