JavaScript IndexedDB Explained: The Browser’s Real Database
Where localStorage (Lesson 7.5) is a small synchronous key-value store, IndexedDB is the browser's actual database. Hundreds of MB to GB of capacity, async APIs, indexed queries, schema/versioning, transactions. It's what offline-first apps use to cache data, what PWAs use to persist between sessions, and what every browser-side database library (Dexie, idb, RxDB) wraps.
This lesson covers the IndexedDB mental model, the verbose native API, and the practical idb library that hides 80% of the verbosity. By the end you'll know when to reach for IndexedDB and how to use it without writing 200 lines of boilerplate.
When IndexedDB earns its keep #
Reach for IndexedDB when:
- The data is more than a few MB (localStorage's 5 MB limit is too tight)
- You need to query by something other than the primary key (indexes on fields)
- You're caching API responses or building an offline-first experience
- You need to store Blobs, Files, or other binary data
- You can't block the main thread (IndexedDB is async; localStorage is sync)
Skip IndexedDB when:
- A few simple preferences fit in localStorage
- The data is short-lived and re-fetchable on every page load
- A server-side session/database is the source of truth and you don't need offline
The mental model #
IndexedDB is an object database, not relational. The main concepts:
- Database — a named storage area, scoped to your origin.
- Object store — like a table; holds keyed objects. Configured at upgrade time.
- Key — the primary identifier of each record. Can be a property on the object (
keyPath), or auto-incremented. - Index — a queryable secondary key on a property.
- Transaction — a unit of work. Reads or writes. Auto-commits when the JavaScript task ends.
- Cursor — for iterating over many records (instead of fetching all into memory).
- Versioning — schemas evolve via versioned upgrades.
This is a more powerful model than "key/value blob" — but the native API for using it is famously clunky.
The raw API in 20 lines #
Opening a database, creating a store, putting a record, reading it back:
const request = indexedDB.open('myApp', 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore('users', { keyPath: 'id' });
};
request.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('users', 'readwrite');
tx.objectStore('users').put({ id: 1, name: 'Ada' });
const readTx = db.transaction('users');
const getRequest = readTx.objectStore('users').get(1);
getRequest.onsuccess = () => console.log(getRequest.result);
};
Notice the event-callback shape. IndexedDB predates Promises — every operation returns a Request object that fires onsuccess and onerror. Useful to read once, painful to write.
The modern way: the idb library #
The community standard is idb by Jake Archibald (~1 KB gzipped). It wraps the native API in Promises:
import { openDB } from 'idb';
const db = await openDB('myApp', 1, {
upgrade(db) {
db.createObjectStore('users', { keyPath: 'id' });
},
});
await db.put('users', { id: 1, name: 'Ada' });
const user = await db.get('users', 1);
console.log(user); // { id: 1, name: 'Ada' }
Four lines instead of fifteen, and it reads top-to-bottom. Use idb (or a higher-level library like Dexie) for any production code. The rest of this lesson uses idb syntax — the concepts apply to both.
Schema versioning #
Databases evolve. Add a new field, add an index, drop a store — that's a schema migration:
const db = await openDB('myApp', 3, {
upgrade(db, oldVersion, newVersion, tx) {
if (oldVersion < 1) {
db.createObjectStore('users', { keyPath: 'id' });
}
if (oldVersion < 2) {
const store = tx.objectStore('users');
store.createIndex('by-email', 'email', { unique: true });
}
if (oldVersion < 3) {
db.createObjectStore('posts', { keyPath: 'id', autoIncrement: true });
}
},
});
The upgrade callback runs only when the version number increases. Always nest changes in if (oldVersion < N) blocks so users on any older version get every step.
This is one of IndexedDB's strengths — versioning is built in. Compare with localStorage, where any schema change requires manual migration scripts and key-by-key cleanup.
Indexes — querying by fields #
Without an index, you can only look up records by their primary key. With one, you can query by any property:
// In upgrade:
store.createIndex('by-email', 'email', { unique: true });
store.createIndex('by-role', 'role', { unique: false });
// In queries:
const byEmail = db.transaction('users').store.index('by-email');
const user = await byEmail.get('ada@example.com');
const byRole = db.transaction('users').store.index('by-role');
const admins = await byRole.getAll('admin');
Indexes can also be on a function or on multi-part composite keys (['role', 'createdAt']).
Cursors — iterating without loading everything #
When a query might return thousands of records, fetching them all is wasteful. Cursors iterate one record at a time:
const tx = db.transaction('users');
let cursor = await tx.store.openCursor();
let count = 0;
while (cursor) {
if (cursor.value.active) count++;
cursor = await cursor.continue();
}
console.log('Active users:', count);
Useful for streaming exports, building search indexes, computing aggregates without buffering.
Transactions #
All IndexedDB operations happen inside a transaction. A transaction:
- Locks the listed stores while it's active.
- Has a mode:
'readonly'(default) or'readwrite'. - Auto-commits when the current JS task ends with no pending requests.
- Can be aborted with
tx.abort()to roll back changes.
const tx = db.transaction(['users', 'posts'], 'readwrite');
await tx.objectStore('users').put({ id: 1, name: 'Ada' });
await tx.objectStore('posts').put({ id: 1, userId: 1, title: 'Hi' });
await tx.done; // wait for the transaction to commit
With multiple operations, wait on tx.done at the end. Either everything commits, or — if any operation fails — the whole transaction rolls back.
The auto-commit trap #
A transaction commits as soon as the JavaScript task it lives in ends with no further requests pending. This means you can't await an unrelated Promise mid-transaction:
// BUG — fetch finishes after the transaction has committed
const tx = db.transaction('users', 'readwrite');
const data = await fetch('/api/users').then(r => r.json());
await tx.store.put(data); // throws — transaction is already inactive
Fetch all the data first, then open the transaction:
const data = await fetch('/api/users').then(r => r.json());
const tx = db.transaction('users', 'readwrite');
await tx.store.put(data);
await tx.done;
This is the single most common IndexedDB bug. Once you know about it, it's avoidable.
Storing binary data #
IndexedDB happily stores Blobs and ArrayBuffers — useful for caching images, audio, PDFs:
const response = await fetch('/big-image.jpg');
const blob = await response.blob();
await db.put('files', { id: 'avatar', blob, type: blob.type });
// Later:
const record = await db.get('files', 'avatar');
const url = URL.createObjectURL(record.blob);
img.src = url;
No size limit per record (within the quota). Browsers cache massive PWAs (Spotify, Discord, Figma) this way.
Quota and persistence #
Each origin gets a quota. Modern browsers allocate generously (often gigabytes) but they can also clear data under pressure.
To opt into more durable storage:
if ('persist' in navigator.storage) {
const granted = await navigator.storage.persist();
console.log('persistent:', granted);
}
const { quota, usage } = await navigator.storage.estimate();
console.log(`${usage} of ${quota} bytes used`);
persist() requests guaranteed-not-evicted status — usually granted to sites the user has bookmarked, installed as a PWA, or interacted with extensively.
A worked example: simple caching layer #
import { openDB } from 'idb';
const dbPromise = openDB('api-cache', 1, {
upgrade(db) {
db.createObjectStore('responses', { keyPath: 'url' });
},
});
export async function cachedFetch(url, ttlMs = 60_000) {
const db = await dbPromise;
const cached = await db.get('responses', url);
if (cached && Date.now() - cached.at < ttlMs) {
return cached.data;
}
const res = await fetch(url);
const data = await res.json();
await db.put('responses', { url, data, at: Date.now() });
return data;
}
30 lines, you have an offline-aware fetch wrapper that survives browser restarts. Service Workers (Lesson 11.4) integrate even more deeply with this pattern.
When to reach for higher-level libraries #
idb— thin Promise wrapper. Use for any direct IndexedDB code.idb-keyval— a pure KV (key/value) API for the simplest cases. "localStorage but async and bigger."Dexie.js— query builder, hooks, relationships, observable queries. Reads like an ORM.RxDB— full reactive database with replication (to a server, between tabs, between devices).
For most apps, idb or idb-keyval is enough. Move up the stack when you need queries by multiple fields, schema migrations, or sync.
A summary #
- IndexedDB is the real browser database — async, indexed, transactional, gigabyte-capable.
- The native API is verbose and event-callback shaped. Use
idb(orDexie) for production. - Stores hold keyed objects; indexes enable queries by other fields.
- Versioning is built in — schema migrations in the
upgradecallback. - Transactions auto-commit at end of task. Don't
awaitunrelated work mid-transaction. - Binary data (Blobs) is first-class.
navigator.storage.persist()opts into eviction protection.
What's next #
Lesson 11.2 covers the observer APIs — IntersectionObserver, ResizeObserver, MutationObserver — the modern event-driven primitives that replaced scroll handlers, resize listeners, and DOM-mutation polling.
Try it yourself #
The "transaction commits at end of task" trap is the easiest one to feel. Predict what happens:
const tx = db.transaction('store', 'readwrite');
await tx.store.put({ id: 1, name: 'Ada' });
const data = await fetch('/api/data').then(r => r.json());
await tx.store.put({ id: 2, data });put will throw InvalidStateError: The transaction is inactive.Between the two puts,
fetch is awaited. While JavaScript is waiting on the network, the original task completes — and IndexedDB auto-commits the transaction. By the time fetch resolves and the second put runs, the transaction is closed.Fix: fetch the data before opening the transaction, then do both puts back-to-back.
The auto-commit-at-end-of-task rule is the #1 trap for newcomers to IndexedDB. Once you know it, you organize code around it naturally.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


