IndexedDB
The browser's built-in asynchronous, transactional database — for when localStorage's limits become a real problem.
Overview
IndexedDBAn asynchronous, transactional, object-oriented database built into the browser, with much higher storage limits than LocalStorage.Learn more is the browser's built-in asynchronous, transactional, object-oriented database — the option that exists specifically for when localStorage's ~5–10MB synchronous, strings-only model isn't enough. It stores real JavaScript values (not just strings), holds hundreds of megabytes or more, and never blocks the main thread the way a large localStorage read or write can.
Why It Exists
Some client-side apps genuinely need a real database, not a key-value cache — an offline-first app that stores thousands of records, a tool that caches large files, anything that needs to query its local data instead of just fetching it by a single key. localStorage's synchronous API and small quota make it actively wrong for that job. IndexedDB was standardized specifically to give the browser a proper database engine — with its own object stores, indexes, and transactional guarantees — without ever freezing the UI while it works.
Real World Example
A note-taking app that works fully offline stores every note as a complete object — title, body, tags, timestamps — in an IndexedDB object store, not squeezed into a single stringified localStorage key. It can query notes by an indexed field like `updatedAt` or `tag` directly, without loading and scanning every note into memory first, and syncs to a server only when the connection comes back.
Example Data
notes object store — each record is a real object, not a string
| id (key) | title | tag | updatedAt |
|---|---|---|---|
| 1 | Grocery list | personal | 2026-08-01T09:15:00 |
| 2 | Sprint planning notes | work | 2026-08-02T14:30:00 |
The Core Concepts, One at a Time
Database & Object Stores
A database holds one or more object stores — the rough equivalent of a table, except each record is a full JavaScript value, not a row of typed columns. Every object store needs a key, either a designated key path on the stored object (like `id`) or an auto-incrementing number the database assigns.
const store = db.createObjectStore('notes', { keyPath: 'id' });Transactions
Every read or write happens inside a transaction, scoped to one or more object stores and a mode (`readonly` or `readwrite`). A transaction auto-commits as soon as it has no more pending requests queued — there's no explicit commit call, which is the source of one of IndexedDB's most common bugs.
const tx = db.transaction('notes', 'readwrite');
tx.objectStore('notes').put(note);Indexes
An index lets you look up records by a field other than the primary key, without scanning every record — the same core idea as a SQL index, applied to an object store instead of a table.
store.createIndex('by_tag', 'tag');Cursors
A cursor lets you iterate over records in an object store or index one at a time, in key order, instead of loading everything into memory at once — essential once a store holds more records than you'd want to pull back in a single array.
index.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) { /* use cursor.value */ cursor.continue(); }
};Diagram
Every operation runs inside a transaction, scoped to one or more object stores
Open database
indexedDB.open('appDB', version)
onupgradeneeded
create/modify object stores & indexes
Start a transaction
readonly or readwrite
Read / write records
via the object store or an index
Transaction auto-commits
once no requests remain pending
Syntax
const request = indexedDB.open("appDB", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore("notes", { keyPath: "id" });
store.createIndex("by_tag", "tag");
};
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").put({ id: 1, title: "Grocery list", tag: "personal" });
};Common Mistakes
Treating IndexedDB calls as if they return a value synchronously
Why: Every meaningful operation returns an IDBRequest object immediately, with the real result only available later in its onsuccess callback — code that reads the return value directly just gets the request object, not your data.
Fix: Always read results from onsuccess (or wrap IndexedDB in a Promise-based helper library like `idb`), never from the call's direct return value.
Creating or modifying object stores outside of onupgradeneeded
Why: Object stores and indexes can only be created or changed inside the onupgradeneeded event, which fires only when the database is first created or its version number is bumped — trying to do it elsewhere throws.
Fix: Put all schema changes inside onupgradeneeded, and gate new changes on comparing `event.oldVersion` so you don't recreate stores that already exist.
Awaiting an unrelated async call (like a fetch) in the middle of a transaction
Why: A transaction auto-commits as soon as its request queue goes idle — awaiting something unrelated lets the event loop tick, the transaction closes, and the next operation you attempt on it throws a 'transaction has finished' error.
Fix: Keep every operation that belongs to one transaction tightly sequenced — do unrelated async work (like a fetch) before starting the transaction or after it completes, never in the middle.
Interview Questions
What's the main practical difference between IndexedDB and localStorage?
IndexedDB is asynchronous, stores real JavaScript values instead of only strings, and holds far more data — hundreds of megabytes or more, versus localStorage's roughly 5–10MB synchronous string-only model.
Why is IndexedDB asynchronous when localStorage isn't?
IndexedDB is built for larger amounts of data and disk-backed operations that can genuinely take noticeable time. Making it synchronous would mean a large read or write could freeze the entire page's main thread — the async, event-based API exists specifically to avoid that.
A transaction closes unexpectedly partway through a sequence of operations. What's the most likely cause, and how would you fix it?
The most common cause is awaiting an unrelated asynchronous operation — a fetch call, a timeout — between two IndexedDB requests that were meant to share the same transaction. Since a transaction auto-commits once its request queue is empty, that gap lets it close before the next operation runs. The fix is restructuring the code so all operations belonging to one transaction are issued back-to-back, without an unrelated await in between.
Production Best Practices
Do
✓Do all schema creation/changes inside onupgradeneeded, gated on the version number.
✓Keep each transaction's operations tightly sequenced, with no unrelated async work in between.
✓Use indexes for any field you query by other than the primary key.
Don't
✗Don't expect a direct return value from an IndexedDB call — always read results from onsuccess.
✗Don't await unrelated async work in the middle of a transaction.
✗Don't load an entire large object store into memory when a cursor or an indexed query would do.
Comparison
| localStorage | IndexedDB | |
|---|---|---|
| API style | Synchronous | Asynchronous |
| Capacity | ~5–10MB | Hundreds of MB+ |
| Data types | Strings only | Structured JS values |
| Query capability | By exact key only | By key, index, or cursor |