What you'll learn
Quick Answer
IndexedDB is an asynchronous, transactional database built into every browser. It stores structured JavaScript objects (not just strings) in object stores, supports indexes for querying by field, and holds far more data thanlocalStorage. Every read and write happens inside a transaction, and the schema - which stores and indexes exist - can only be changed inside a specialonupgradeneededevent tied to a version number.
Why localStorage isn't enough
localStorage is fine for a theme preference and wrong for almost anything bigger. It is synchronous, so every read blocks the main thread. It caps out around 5MB. It stores strings only, so every object goes through JSON.stringify. And it has no indexing - to find one record you load and scan all of them.
IndexedDB fixes each of those: it is asynchronous, it stores objects, Blobs, and ArrayBuffers directly, it holds hundreds of megabytes to gigabytes depending on the browser and free disk space, and it supports indexes for fast lookups by field. It is also transactional, so a batch of writes either all land or all roll back.
The price is a clunky, event-based API from 2015. That is why wrappers like idb, Dexie, and localForage exist. Learn the raw API first - the concepts underneath the wrappers are what actually bite you.
Opening a database and defining the schema
indexedDB.open(name, version) returns a request with three events that matter: onupgradeneeded (fires on first creation, or whenever you open with a higher version number), onsuccess, and onerror. Object stores and indexes can only be created inside onupgradeneeded.
import 'fake-indexeddb/auto'; // gives Node the same global indexedDB a browser has
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('notes-app', 1);
req.onupgradeneeded = () => {
const store = req.result.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
store.createIndex('by_tag', 'tag', { unique: false });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
const db = await openDB();
// one fresh transaction per operation; resolve on the request's result
function run(mode, fn) {
return new Promise((resolve, reject) => {
const req = fn(db.transaction('notes', mode).objectStore('notes'));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
const wtx = db.transaction('notes', 'readwrite');
for (const n of [{ title: 'Buy milk', tag: 'errand' },
{ title: 'Call bank', tag: 'errand' },
{ title: 'Read spec', tag: 'work' }]) wtx.objectStore('notes').add(n);
await new Promise((r) => (wtx.oncomplete = r));
console.log('note #1:', await run('readonly', (s) => s.get(1)));
console.log('errand notes:', (await run('readonly', (s) => s.index('by_tag').getAll('errand'))).map((n) => n.title));
console.log('missing key 999 returns:', await run('readonly', (s) => s.get(999)), '(not an error)');note #1: { title: 'Buy milk', tag: 'errand', id: 1 }
errand notes: [ 'Buy milk', 'Call bank' ]
missing key 999 returns: undefined (not an error)keyPath: 'id' makes each object's id property its key; autoIncrement generates it. The index lets you query by tag without scanning. Note the last line: getting a key that does not exist resolves with undefined - it does not throw and onerror never fires. Code that waits for an error to detect "not found" waits forever.
Everything happens inside a transaction - which expires fast
You never touch a store directly. You call db.transaction(storeNames, mode) where mode is 'readonly' or 'readwrite', then get the store from that transaction. A readwrite transaction is atomic: if any request in it fails unhandled, every write in it rolls back.
Now the subtlety that catches everyone. A transaction stays alive only while it has IndexedDB work pending. The instant control returns to the event loop with nothing queued on it, the transaction auto-commits and goes inactive. So if you await anything that is not an IndexedDB request - a fetch, a setTimeout, a worker message - in the middle of a transaction, it commits during that await, and the next call on it throws:
const store = await new Promise((resolve) => {
const t = db.transaction('items', 'readwrite');
const s = t.objectStore('items');
s.put({ id: 1, name: 'first' });
t.oncomplete = () => resolve(s); // transaction is now finished
});
try {
store.put({ id: 2, name: 'second' }); // reusing a handle from a finished transaction
} catch (err) {
console.log(`${err.name}: ${err.message}`);
}TransactionInactiveError: A request was placed against a transaction which is currently not active, or which is finished.The fix: gather any external data before you open the transaction, do all the store operations synchronously inside it, and only use promise wrappers that chain IndexedDB requests (never a foreign promise) so the transaction stays alive.
Schema changes and the version number
The version number is how you migrate. Bump it, and onupgradeneeded fires with event.oldVersion and event.newVersion. You branch on oldVersion to apply only the steps a given user is missing - exactly like server-side database migrations.
// App v1 shipped with only this
function openV1() { /* open('app-db', 1) -> createObjectStore('users') */ }
// App v2 shipped later, same DB name, version bumped to 2
function openV2() {
const req = indexedDB.open('app-db', 2);
req.onupgradeneeded = (e) => {
const db = req.result;
if (e.oldVersion < 1) db.createObjectStore('users', { keyPath: 'id' });
if (e.oldVersion < 2) db.createObjectStore('sessions', { keyPath: 'token' });
};
}first launch, app v1:
upgrade 0 -> 1
stores: [ 'users' ]
reopen, still app v1 (version unchanged):
stores: [ 'users' ] - onupgradeneeded did not run
user updates to app v2:
upgrade 1 -> 2
stores: [ 'sessions', 'users' ]Three things to know. You cannot lower a version - opening with a number below the stored one throws. Creating an object store that already exists throws and aborts the whole upgrade. And the upgrade runs a special versionchange transaction that is blocked while another tab holds the database open at the old version; handle the blocked event or your upgrade hangs.
Using it without losing your mind
Wrap it. The raw API is four event handlers per operation. Jake Archibald's idb is a roughly 1KB promise wrapper that keeps transaction semantics correct; Dexie adds a query builder; localForage gives a localStorage-style API on top.
Storage is not guaranteed permanent. By default the browser may evict your database under storage pressure. Call navigator.storage.persist() to request durable storage for an app that needs it.
A few more: IndexedDB is origin-scoped, so app.example.com and example.com get separate databases. Private and incognito windows often give a working database that is wiped on close, or a tiny quota - feature-detect and degrade. And when writing many records, put them in one transaction rather than one transaction each; it is far faster and atomic. Test with realistic data volumes, because a cursor over 50,000 rows behaves nothing like one over 5.
