IndexedDB
IndexedDB is a full database baked into the browser. Where localStorage gives you a tiny string-to-string map, IndexedDB gives you something closer to a real datastore.
- It stores almost any kind of value under a key, and keys themselves can be several different types.
- It runs operations inside transactions, so a group of writes either all lands or none of it does.
- It can query ranges of keys and search secondary fields through indexes.
- It holds far more data than
localStorage— enough to keep a whole app’s dataset offline.
For a plain client-server app, most of that is overkill. IndexedDB earns its keep in offline-capable apps, usually paired with Service Workers and other background machinery so the app keeps working with no network.
The native API, defined in the specification at https://www.w3.org/TR/IndexedDB, is event-based. You fire off a request, then wait for success or error events on it.
You can also drive it with async/await through a promise-based wrapper such as idb. That reads much nicer, but the wrapper can’t cover every case events handle. So we’ll learn the event API first, build a solid mental model, and switch to the wrapper once the ideas are clear.
Opening a database
Before you can read or write anything, you open (connect to) a database.
let openRequest = indexedDB.open(name, version);
name— a string naming the database.version— a positive integer version,1by default. We’ll get to why version matters in a moment.
You can keep many databases side by side under different names, but every one of them belongs to the current origin (protocol + domain + port). A database created by one site is invisible to another.
The open call doesn’t return a database. It returns an openRequest object, and you listen for events on it:
success— the database is ready. The database object lives inopenRequest.result, and that’s what you use from here on.error— opening failed.upgradeneeded— the database is ready but its stored version is behind the version you asked for (more below).
IndexedDB ships with a schema-versioning mechanism that server databases simply don’t have.
Here’s why it needs one. A server database sits on your machine, under your full control — you migrate it whenever you deploy. An IndexedDB database sits in each visitor’s browser, out of your reach. When you push a new version of your app and a returning visitor loads it, their local database might be stale and need upgrading on the spot.
If the version stored in the browser is lower than the one you pass to open, the browser fires an upgradeneeded event. That’s your chance to inspect the old version and reshape the data structures.
The same upgradeneeded event fires when the database doesn’t exist yet — its version is treated as 0 — so it doubles as your one-time initialization hook.
Say you’ve shipped the first release of your app. Open the database at version 1 and do first-time setup inside upgradeneeded:
let openRequest = indexedDB.open("store", 1);
openRequest.onupgradeneeded = function() {
// fires when the visitor has no database yet
// ...perform initialization...
};
openRequest.onerror = function() {
console.error("Error", openRequest.error);
};
openRequest.onsuccess = function() {
let db = openRequest.result;
// carry on working with the database through the db object
};
Later you ship release two. Now open at version 2 and handle the jump:
let openRequest = indexedDB.open("store", 2);
openRequest.onupgradeneeded = function(event) {
// the existing database version is below 2 (or it doesn't exist yet)
let db = openRequest.result;
switch(event.oldVersion) { // the version already on disk
case 0:
// 0 means the visitor had no database at all
// perform first-time initialization
case 1:
// the visitor was on version 1
// migrate it forward
}
};
Notice the deliberate lack of break statements. Because the current version is 2, the handler covers version 0 (a brand-new visitor with nothing) and version 1 (someone upgrading). A visitor coming from 0 falls through both cases and runs the full setup chain; a visitor on 1 only runs the case 1 migration.
Only after upgradeneeded completes without throwing does openRequest.onsuccess fire and the database count as open.
To wipe a database entirely:
let deleteRequest = indexedDB.deleteDatabase(name)
// deleteRequest.onsuccess / onerror report the outcome
The parallel-update problem
Versioning brings one sharp edge worth handling up front. Picture this:
- A visitor opens your site in a tab, connected to database version
1. - You deploy an update — your code is now newer.
- The same visitor opens your site in a second tab.
The first tab still holds an open connection to version 1. The second tab, running the new code, tries to bump the database to version 2 in its upgradeneeded handler.
DB version 1
DB version 2
The catch: both tabs share one database, because it’s the same origin. It can’t be version 1 and version 2 at once. To reach version 2, every connection to version 1 has to close first — including the one in the first tab.
To coordinate this, the browser fires a versionchange event on the outdated database object. You listen for it, close that stale connection, and probably nudge the visitor to reload so they pick up the new code.
Skip that listener and the story ends badly: the second, newer connection can never open. Instead of success, its openRequest gets a blocked event, and the second tab is stuck.
Here’s the full handling for a clean parallel upgrade. The onversionchange handler fires when this connection has become the outdated one and closes it:
let openRequest = indexedDB.open("store", 2);
openRequest.onupgradeneeded = ...;
openRequest.onerror = ...;
openRequest.onsuccess = function() {
let db = openRequest.result;
db.onversionchange = function() {
db.close();
alert("The database is outdated, please reload the page.")
};
// ...the db is ready, use it...
};
openRequest.onblocked = function() {
// shouldn't fire if onversionchange is handled correctly
// it means another open connection to the same database
// never closed after its db.onversionchange fired
};
So there are two complementary listeners:
db.onversionchangetells this connection that something elsewhere is trying to upgrade past it — time to step aside.openRequest.onblockedtells a new connection that some older connection refuses to close, so the upgrade can’t proceed.
You can make db.onversionchange friendlier — prompt the visitor to save unsaved work before closing, for instance. An alternative design flips it around: don’t auto-close in onversionchange; instead let the new tab’s onblocked handler tell the visitor the new version can’t load until the other tabs are closed.
These collisions are rare, but leave your app with at least an onblocked handler so it fails loudly instead of dying in silence.
Object stores
To keep anything in IndexedDB you need an object store.
The object store is the central concept here. Other databases call the equivalent a “table” or a “collection” — it’s the container the data lives in. One database can hold several stores: one for users, one for products, and so on.
Despite the name, stores aren’t limited to objects.
A store can hold almost any value, including deeply nested objects.
IndexedDB clones and stores values with the structured serialization algorithm. Think of it as JSON.stringify with a much wider reach — it handles Date, Blob, typed arrays, Map, Set, and more.
What it can’t store is an object with circular references — those aren’t serializable, and JSON.stringify chokes on them too.
Every value in a store needs a unique key.
A key must be a number, date, string, binary, or array. It’s the value’s identifier — you look up, update, and delete records by key.
You can hand IndexedDB a key each time you add a value, the way you pass one to localStorage. But for objects there are two nicer options: point IndexedDB at a property to use as the key, or let it auto-generate keys for you.
First, though, the store itself has to exist.
db.createObjectStore(name[, keyOptions]);
This call is synchronous — no request, no await.
nameis the store’s name, e.g."teas".keyOptionsis optional and carries one of two properties:keyPath— a path to the object property IndexedDB uses as the key, e.g.id.autoIncrement— iftrue, each new record gets an automatically generated, ever-increasing number as its key.
Leave keyOptions out and you’ll have to supply an explicit key yourself every time you store something.
This store uses each tea’s id property as its key:
db.createObjectStore('teas', {keyPath: 'id'});
Stores can only be created, removed, or altered while the DB version is changing — inside an upgradeneeded handler.
That’s a hard rule of the API. Outside the handler you can freely add, read, update, and delete records, but the structure — which stores and indexes exist — is frozen except during a version upgrade.
When it’s time to migrate, two strategies work:
- Write per-version upgrade steps: 1→2, 2→3, 3→4, and so on. In
upgradeneeded, compare the old and new versions and run each intermediate step in turn (old2, now4→ run 2→3, then 3→4). - Or just inspect the current shape.
db.objectStoreNamesis a DOMStringList with acontains(name)method, so you check what exists and create only what’s missing.
For a small database the second approach is often less code:
let openRequest = indexedDB.open("db", 2);
// create / upgrade the database without explicit version checks
openRequest.onupgradeneeded = function() {
let db = openRequest.result;
if (!db.objectStoreNames.contains('teas')) { // no "teas" store yet
db.createObjectStore('teas', {keyPath: 'id'}); // create it
}
};
To drop a store:
db.deleteObjectStore('teas')
Transactions
“Transaction” is a general database term, and IndexedDB uses it in the usual sense.
A transaction is a bundle of operations that must either all succeed or all fail together.
Take a purchase. You need to:
- Deduct the money from the buyer’s account.
- Add the item to their inventory.
If step 1 goes through and then the power cuts out before step 2, you’ve charged someone for nothing. Both steps should commit together (purchase done) or both should roll back (at least the buyer keeps their money and can retry). Transactions give you exactly that guarantee.
In IndexedDB, every data operation happens inside a transaction.
Starting one:
db.transaction(store[, type]);
storeis the store name the transaction will touch, e.g."teas". Pass an array of names to span multiple stores.typeis one of:readonly— reads only. The default.readwrite— reads and writes data, but still can’t create, remove, or alter stores.
There’s a third type, versionchange, that can do everything, including structural changes — but you can’t create it by hand. The browser creates a versionchange transaction automatically when it runs your upgradeneeded handler, which is precisely why that handler is the only place structure can change.
With a transaction open, you get the store from it and add a record:
let transaction = db.transaction("teas", "readwrite"); // (1)
// get the object store to operate on
let teas = transaction.objectStore("teas"); // (2)
let tea = {
id: 'oolong',
price: 10,
created: new Date()
};
let request = teas.add(tea); // (3)
request.onsuccess = function() { // (4)
console.log("Tea added to the store", request.result);
};
request.onerror = function() {
console.log("Error", request.error);
};
Four steps, always in this shape:
- Open a transaction, naming every store it will access —
(1). - Get the store object with
transaction.objectStore(name)—(2). - Issue the request, here
teas.add(tea)—(3). - Handle its
success/error, then fire more requests if needed —(4).
Two methods store a value:
-
put(value, [key])Addsvalueto the store. Passkeyonly when the store has neitherkeyPathnorautoIncrement. If a record with that key already exists,putoverwrites it. -
add(value, [key])Same asput, except a colliding key makes the request fail with a"ConstraintError". Useaddwhen a duplicate should be treated as a mistake.
Just like opening a database, the store request (teas.add(tea)) resolves through success / error events.
- For
add,request.resultis the key of the new record. - Any failure lands in
request.error.
How transactions auto-commit
Above we opened a transaction and ran one add. But a transaction can hold many requests that must all commit or all roll back. So how do you tell it “I’m done, no more requests”?
You don’t.
A future 3.0 revision of the spec may add a manual finish, but the 2.0 API in wide use has none.
Once every pending request in a transaction has finished and the microtask queue has drained, the transaction commits on its own.
In practice: a transaction commits when its requests are all done and the current stretch of synchronous code finishes. No explicit call needed — the earlier example didn’t need one.
This auto-commit rule has a consequence that trips people up. You can’t drop an asynchronous operation like fetch or setTimeout into the middle of a transaction. IndexedDB won’t hold the transaction open waiting for it.
Below, request2 on line (*) fails, because by the time fetch resolves the transaction has long since committed and closed:
let request1 = teas.add(tea);
request1.onsuccess = function() {
fetch('/').then(response => {
let request2 = teas.add(anotherTea); // (*)
request2.onerror = function() {
console.log(request2.error.name); // TransactionInactiveError
};
});
};
fetch schedules a macrotask, and transactions close before the browser gets around to macrotasks.
The spec authors deliberately made transactions short-lived, chiefly for performance. Remember readwrite transactions lock their stores for writing: if one part of the app opens a readwrite on teas, another part wanting the same store queues up behind it. Long-running transactions turn that into visible stalls.
So what do you do when you need fetch and a write together? Don’t interleave them. Fetch first, prepare the data, and only then open the transaction and run all the database requests back to back.
To know a transaction landed, listen for transaction.oncomplete:
let transaction = db.transaction("teas", "readwrite");
// ...perform operations...
transaction.oncomplete = function() {
console.log("Transaction is complete");
};
Only complete proves the whole transaction was saved. Individual requests can succeed while the final commit still fails — an I/O error, say.
To roll a transaction back yourself:
transaction.abort();
That undoes every change its requests made and fires transaction.onabort.
Handling errors
Write requests fail sometimes.
Expect it — and not only from bugs in your code. External causes count too, like blowing past the storage quota. Your code has to cope.
A failed request aborts its transaction automatically, throwing away every change in it.
Occasionally you’d rather handle the failure — try a different request, maybe — without losing the changes so far, and let the transaction continue. That’s allowed: call event.preventDefault() in request.onerror to stop the automatic abort.
Below, a tea is added with the same id as an existing one, so add raises a "ConstraintError". We handle it and keep the transaction alive:
let transaction = db.transaction("teas", "readwrite");
let tea = { id: 'oolong', price: 10 };
let request = transaction.objectStore("teas").add(tea);
request.onerror = function(event) {
// ConstraintError fires when a record with this id already exists
if (request.error.name == "ConstraintError") {
console.log("A tea with that id already exists"); // handle it
event.preventDefault(); // keep the transaction alive
// maybe retry with a different key
} else {
// an error we can't handle
// let the transaction abort
}
};
transaction.onabort = function() {
console.log("Error", transaction.error);
};
Error delegation through bubbling
Do you need onerror / onsuccess on every single request? No. You can delegate.
IndexedDB errors bubble: request → transaction → database.
These are real DOM events, with capturing and bubbling phases, though in practice you work at the bubbling stage.
So a single db.onerror can catch failures from anywhere, handy for logging:
db.onerror = function(event) {
let request = event.target; // the request that failed
console.log("Error", request.error);
};
But if a specific error was already dealt with, you don’t want it bubbling up and getting logged as unhandled. Stop it at the source with event.stopPropagation():
request.onerror = function(event) {
if (request.error.name == "ConstraintError") {
console.log("A tea with that id already exists"); // handle it
event.preventDefault(); // don't abort the transaction
event.stopPropagation(); // don't bubble up — swallow it here
} else {
// do nothing
// the transaction will abort
// we can deal with it in transaction.onabort
}
};
Searching
There are two ways to look things up in a store:
- By key, or by a range of keys. In the
teasstore that means searching bytea.id. - By some other field, e.g.
tea.price. This needs an extra structure called an index.
By key
Start with searching by key.
The search methods accept an exact key or a range — an IDBKeyRange object that describes which keys count as matches.
You build ranges with these constructors:
IDBKeyRange.lowerBound(lower, [open])— keys≥ lower(or> lowerwhenopenis true).IDBKeyRange.upperBound(upper, [open])— keys≤ upper(or< upperwhenopenis true).IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen])— keys betweenlowerandupper; eachopenflag excludes that endpoint.IDBKeyRange.only(key)— a range holding a singlekey. Rarely used.
Drag the bounds and flip the open flags below to see exactly which keys a bound(...) range selects. The open flags decide whether each endpoint itself counts as a match:
The methods themselves take a query that’s either an exact key or a range:
store.get(query)— the first value matching the key or range.store.getAll([query], [count])— all matching values, capped atcountif given.store.getKey(query)— the first key matching the query, usually a range.store.getAllKeys([query], [count])— all matching keys, capped atcountif given.store.count([query])— how many keys match.
Since id is the key in the teas store, all of these search by id:
// one tea
teas.get('oolong')
// teas with 'chai' <= id <= 'matcha'
teas.getAll(IDBKeyRange.bound('chai', 'matcha'))
// teas with id < 'matcha'
teas.getAll(IDBKeyRange.upperBound('matcha', true))
// every tea
teas.getAll()
// every key where id > 'oolong'
teas.getAllKeys(IDBKeyRange.lowerBound('oolong', true))
By a field, using an index
To search by a field other than the key, you build an index.
An index is an add-on attached to a store that tracks one field. For each distinct value of that field, it holds the list of primary keys whose records carry that value. The picture below makes it concrete.
objectStore.createIndex(name, keyPath, [options]);
name— the index’s name.keyPath— the field the index tracks (the field you want to search).options— an optional object:unique— when true, only one record may hold a given value atkeyPath; a duplicate raises an error.multiEntry— matters only when the tracked value is an array. By default the whole array is treated as one key; withmultiEntrytrue, each element becomes its own index key, so a record shows up once per array member.
The teas store is keyed by id. Say we want to search by price. First create the index — and like a store, it has to happen in upgradeneeded:
openRequest.onupgradeneeded = function() {
// indexes must be created here, in the versionchange transaction
let teas = db.createObjectStore('teas', {keyPath: 'id'});
let index = teas.createIndex('price_idx', 'price');
};
- The index tracks the
pricefield. - Prices aren’t unique — several teas can cost the same — so we skip
unique. - Price isn’t an array, so
multiEntrydoesn’t apply.
With four teas in stock, here’s what the index actually looks like:
For each price value, the index keeps the list of primary keys of teas at that price. It maintains itself as records change — you never update it by hand.
Searching the index uses the very same methods as a store, just called on the index object:
let transaction = db.transaction("teas"); // readonly
let teas = transaction.objectStore("teas");
let priceIndex = teas.index("price_idx");
let request = priceIndex.getAll(10);
request.onsuccess = function() {
if (request.result !== undefined) {
console.log("Teas", request.result); // every tea with price == 10
} else {
console.log("No such teas");
}
};
Ranges work here too, to pull cheap or pricey teas:
// teas where price <= 5
let request = priceIndex.getAll(IDBKeyRange.upperBound(5));
An index is sorted by the field it tracks (price here), so index searches return results in price order rather than id order.
Deleting records
delete finds records to remove by query, with the same call shape as getAll:
delete(query)— remove records matching the query.
// delete the tea with id 'oolong'
teas.delete('oolong');
To delete by a non-key field, find the primary key through the index first, then delete by that key:
// find the key of a tea priced at 5
let request = priceIndex.getKey(5);
request.onsuccess = function() {
let id = request.result;
let deleteRequest = teas.delete(id);
};
To empty a store:
teas.clear(); // remove every record
Cursors
getAll and getAllKeys return everything as one array. That’s fine until the store outgrows available memory — then getAll can’t build the array at all.
A cursor is the answer.
A cursor walks a store (or a query’s matches) and yields one key/value pair at a time, so memory stays flat no matter how big the store gets.
Because a store is sorted by key, the cursor moves through it in key order, ascending by default.
// like getAll, but streamed through a cursor:
let request = store.openCursor(query, [direction]);
// for keys only (like getAllKeys): store.openKeyCursor
query— a key or range, same asgetAll.direction— optional traversal order:"next"— the default; climb from the lowest key upward."prev"— descend from the highest key downward."nextunique"/"prevunique"— same directions, but skip duplicate keys. Only meaningful on index cursors, where several records can share a key — you get just the first at each key.
The defining trait of a cursor: request.onsuccess fires once per record, not once total.
let transaction = db.transaction("teas");
let teas = transaction.objectStore("teas");
let request = teas.openCursor();
// runs once for each tea the cursor reaches
request.onsuccess = function() {
let cursor = request.result;
if (cursor) {
let key = cursor.key; // the tea's key (id field)
let value = cursor.value; // the tea object
console.log(key, value);
cursor.continue();
} else {
console.log("No more teas");
}
};
key ‘chai’
key ‘matcha’
key ‘oolong’
cursor = null
Press cursor.continue() below to feel the rhythm: each click re-fires onsuccess, hands you the next record in key order, and eventually delivers a null cursor once the store is exhausted.
The two methods that advance a cursor:
advance(count)— jump forwardcountrecords, skipping the ones in between.continue([key])— step to the next matching record, or to the record at (or just after)keyif you pass one.
Either way, onsuccess fires again: result holds the cursor at its new position, or null/undefined once nothing is left.
That example ran a cursor over a store. You can also run one over an index — same memory savings, streaming one record at a time. On an index cursor, cursor.key is the index key (the price), and the record’s own key lives in cursor.primaryKey:
let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));
// runs once per record
request.onsuccess = function() {
let cursor = request.result;
if (cursor) {
let primaryKey = cursor.primaryKey; // the store key (id field)
let value = cursor.value; // the store object (tea)
let key = cursor.key; // the index key (price)
console.log(key, value);
cursor.continue();
} else {
console.log("No more teas");
}
};
The promise wrapper
Hanging onsuccess / onerror on every request gets tedious. Event delegation trims some of it, but async/await is what you really want.
The rest of this chapter uses the thin idb wrapper. It exposes a global idb object with promisified IndexedDB methods.
Instead of event handlers, the code reads top to bottom:
let db = await idb.openDB('store', 1, db => {
if (db.oldVersion == 0) {
// first-time initialization
db.createObjectStore('teas', {keyPath: 'id'});
}
});
let transaction = db.transaction('teas', 'readwrite');
let teas = transaction.objectStore('teas');
try {
await teas.add(...);
await teas.add(...);
await transaction.complete;
console.log('teas saved');
} catch(err) {
console.log('error', err.message);
}
Plain sequential code, plus ordinary try..catch.
Error handling with promises
An error you don’t catch propagates outward to the nearest enclosing try..catch, just like any thrown value.
If nothing catches it, it surfaces as an unhandledrejection event on window:
window.addEventListener('unhandledrejection', event => {
let request = event.target; // the native IndexedDB request
let error = event.reason; // the unhandled error, same as request.error
// ...report the error...
});
The “inactive transaction” trap
The auto-commit rule doesn’t go away under the wrapper. A transaction still commits the moment the browser finishes the current code and its microtasks. Slip a macrotask like fetch into the middle and the transaction won’t wait — it commits, and the next request against it fails.
Here it is with async/await:
let transaction = db.transaction("inventory", "readwrite");
let inventory = transaction.objectStore("inventory");
await inventory.add({ id: 'oolong', price: 10, created: new Date() });
await fetch(...); // (*)
await inventory.add({ id: 'oolong', price: 10, created: new Date() }); // Error
The inventory.add after fetch at (*) throws an “inactive transaction” error — the transaction closed while fetch was in flight.
The fix is the one from the native API: don’t mix fetch into a transaction. Split the work in two.
- Fetch and prepare everything you need first.
- Then open the transaction and write.
Reaching the native objects
Under the hood the wrapper issues a native request, attaches onerror / onsuccess, and hands back a promise that resolves or rejects with the result. That’s enough almost always.
In the rare case you need the underlying request, it hangs off the promise as promise.request:
let promise = teas.add(tea); // grab the promise, don't await yet
let request = promise.request; // native request object
let transaction = request.transaction; // native transaction object
// ...do some low-level IndexedDB work...
let result = await promise; // await the result if you still need it
Summary
Think of IndexedDB as localStorage with real teeth — a key-value database strong enough for offline apps, still approachable day to day.
The authoritative reference is the specification. The current release is 2.0, and a few methods from the 3.0 draft (not a big departure) already have partial support.
The everyday workflow boils down to:
- Reach for a promise wrapper such as idb.
- Open a database:
idb.openDB(name, version, onupgradeneeded).- Create stores and indexes in
onupgradeneeded, and run version migrations there too.
- Create stores and indexes in
- For every operation:
- Open a transaction —
db.transaction('teas'),readwritewhen you’re writing. - Grab the store —
transaction.objectStore('teas').
- Open a transaction —
- Search:
- By key, call the search methods on the store directly.
- By another field, create an index and search that.
- When the data won’t fit in memory, walk it with a cursor.
Here’s a small store app. The sandboxed preview can’t reach real IndexedDB, so it mimics an object store keyed by id in memory — but the add / put / delete flow, the duplicate-key ConstraintError, and the always-sorted-by-key listing all mirror the real API: