IndexedDB
Store structured data in the browser with IndexedDB: object stores, keys, transactions, indexes, cursors, version upgrades, and small promise wrappers.
- 01Design a browser databaseChoose a database, object stores, keys, and indexes for structured records.
- 02Use transactions safelyGroup reads and writes, handle complete and abort events, and avoid inactive transactions.
- 03Wrap the event APITurn requests and transactions into promises without changing IndexedDB’s rules.
A real database in the browser
IndexedDB is the browser’s built-in database for structured data. It is not a string-only shelf like localStorage. It stores JavaScript values with the structured clone algorithm, runs asynchronously with events, and can hold enough data for serious offline features in most browsers.
You use IndexedDB when an app needs to keep many records between visits: drafts, messages waiting to sync, product catalogs, map tiles, user-generated files, or an offline reading list. It also works in workers, so heavier storage work does not have to block the main page.
IndexedDB is an origin-scoped, asynchronous browser database made of named databases, object stores, records, keys, indexes, and transactions.
Imagine a browser tab opening a filing cabinet owned by this website. The database is the cabinet. Each object store is a drawer. A record is a folder in a drawer. The key is the folder label. An index is a second tab list, so you can find the same folders by author or ISBN without reading every folder.
- In real life: The whole cabinet
- In JavaScript: A database, such as
cf-demo-indexeddb - In real life: A drawer
- In JavaScript: An object store, such as
books - In real life: A folder
- In JavaScript: One stored record object
- In real life: The label on a folder
- In JavaScript: The key, from a
keyPathor out-of-line key - In real life: Alphabetical tabs by author
- In JavaScript: An index, such as
author
Where the analogy stops: A filing cabinet is physical and usually single-user. IndexedDB is asynchronous, versioned, and multiple tabs can hold connections at once.
IndexedDB’s vocabulary sounds large, so this lesson builds one piece at a time: first the cabinet, then a teller-like transaction, then indexes and cursors, then version upgrades, and finally tiny promise helpers.
Databases & object stores
INTERACTIVEYou start with indexedDB.open(name, version). It returns an IDBOpenDBRequest immediately. The browser later fires onupgradeneeded if the database needs schema work, then onsuccess when a connection is ready, or onerror/onblocked when it cannot proceed.
Object stores are where records live. You choose how keys are made: a keyPath reads a property from the stored object, an out-of-line key is passed separately to add(value, key), and autoIncrement can generate numeric keys. Keys can be numbers, strings, Dates, arrays, or binary values.
This replay is a small model of the IndexedDB event order. The playgrounds below run the real browser API and should show the same shape: open, maybe upgrade, success, transaction, request, complete.
script
request.onupgradeneeded = (event) => { const db = request.result; db.createObjectStore("books", { keyPath: "id", autoIncrement: true });}; request.onsuccess = () => { const db = request.result; const tx = db.transaction("books", "readwrite"); const store = tx.objectStore("books"); store.put({ title: "Eloquent JavaScript", author: "Marijn" }); tx.oncomplete = () => db.close();};const request = indexedDB.open("cf-demo-indexeddb", 2);request.onupgradeneeded = () => { const store = db.createObjectStore("books", { keyPath: "id", autoIncrement: true, }); store.createIndex("author", "author", { unique: false }); store.createIndex("isbn", "isbn", { unique: true });}; store.add(book); // fail if key existsstore.put(book); // insert or replacestore.get(1);store.delete(1);store.getAll();Store: books · keyPath: id · autoIncrement: true
This uses the real IndexedDB in your browser. Reset deletes cf-demo-indexeddb, recreates it, and cleanup deletes it again when this component unmounts.
keyPath: id and autoIncrement: true. Try Duplicate add, then Put id 1: add fails for an existing key; put replaces.Notice the difference between add and put. add means “create a new folder only.” If the key already exists, the request fails with ConstraintError. put means “insert or replace.” That makes put useful for saves and add useful when duplicates must be rejected.
IndexedDB stores values with the structured clone algorithm. Dates, Maps, arrays, typed data, and Blobs are okay. Functions and DOM nodes are not data snapshots, so they throw DataCloneError. Store records, not live page elements or behavior.
Transactions
LABA transaction is the unit of work. You open one with selected store names and a mode: readonly for reading, readwrite for changing data, and the special versionchange mode during upgrades. Every request belongs to a transaction.
If you transfer money, you do not want “money left Ada” without “money reached Grace.” A readwrite IndexedDB transaction gives you the same all-or-nothing promise for the stores it controls.
- In real life: Walk up to one teller
- In JavaScript: Start one transaction
- In real life: Withdraw and deposit in the same session
- In JavaScript: Queue related requests
- In real life: Teller stamps complete
- In JavaScript:
transaction.oncompletecommits - In real life: Something goes wrong, papers are voided
- In JavaScript:
abortrolls writes back
Where the analogy stops: A human teller waits while you make a phone call. IndexedDB transactions do not: if there are no pending requests and control returns to the event loop, the transaction may auto-commit.
const tx = db.transaction("accounts", "readwrite");const store = tx.objectStore("accounts");const ada = await promisifyRequest(store.get("ada")); await new Promise((resolve) => setTimeout(resolve, 0)); store.put({ ...ada, balance: ada.balance - 10 });// TransactionInactiveError: the transaction had no pending IndexedDB requests.A readwrite transaction is like one teller session. If it aborts midway, earlier writes roll back. If you await a non-IndexedDB timer while no requests are pending, the transaction can close.
TransactionInactiveError in real browsers that enforce transaction lifetime this way.The sneaky rule is lifetime. Transactions auto-commit when their request queue is empty and the browser gets control back. Awaiting an unrelated timer, network request, or any non-IndexedDB promise in the middle can let the transaction finish. The next store.put then throws TransactionInactiveError in browsers that enforce the rule.
| Event | Meaning | What to do |
|---|---|---|
oncomplete | All requests finished and writes committed. | Update UI, close the connection if you are done. |
onabort | The transaction was canceled or a request failed without being handled. | Tell the user nothing changed; inspect transaction.error. |
onerror | A request error bubbled to the transaction. | Handle or abort deliberately. |
commit() | In newer browsers, asks to commit early when no more requests will be added. | Optional optimization, not required for correctness. |
Indexes & cursors
QUERYThe primary key finds a record by its label. Indexes add other lookup paths. In the playground, author is non-unique because one author can write many books, while isbn is unique because two books should not share the same ISBN.
IDBKeyRange.only(value) means exactly this key. IDBKeyRange.bound(low, high) means between two keys. IDBKeyRange.lowerBound(value) means this key and everything after it. For a batch, getAll(range, count) is compact. For one-at-a-time work, openCursor(range, direction) returns a cursor; call cursor.continue() to move.
const tx = db.transaction("books", "readonly");const byAuthor = tx.objectStore("books").index("author");const range = IDBKeyRange.only("Ada Lovelace");const matches = await promisifyRequest(byAuthor.getAll(range, 3)); const cursorRequest = byAuthor.openCursor(null, "prev");cursorRequest.onsuccess = () => { const cursor = cursorRequest.result; if (!cursor) return; console.log(cursor.key, cursor.value.title); cursor.continue();};Indexes are lookup paths. Key ranges narrow the path, cursors walk records one at a time, and getAll(range, count) grabs a limited batch.
author index is non-unique, so Ada can have two books. The isbn index is unique, so two matching ISBNs would fail.- One named container called
recipe-appfor all saved app data - A
booksdrawer that stores book records - Find every book by
authorwithout scanning all records - Subtract stock and add an order as one all-or-nothing step
- One named container called
offline-reader - The
accountscollection keyed by username - Look up a book by unique
isbn - Move 10 credits from Ada to Grace, or move none
Sort each realistic task into the piece that should handle it.
Versioning
UPGRADEThe second argument to indexedDB.open is a database version number. You can only create or delete object stores and indexes during the onupgradeneeded event, inside its versionchange transaction. That is where you remodel the cabinet.
You cannot add a drawer while people are actively filing papers. IndexedDB has the same rule: every open connection must agree to the new version before schema changes finish.
- In real life: Old cabinet layout
- In JavaScript:
oldVersion - In real life: New cabinet layout
- In JavaScript:
newVersion/db.version - In real life: Add a drawer while remodeling
- In JavaScript:
createObjectStoreinonupgradeneeded - In real life: Everyone must step away first
- In JavaScript: Open tabs receive
versionchange; upgrades may beblocked
Where the analogy stops: A remodel can happen once and be done. Web users may have old tabs open for hours, so production apps must handle blocked upgrades kindly.
const first = indexedDB.open("cf-demo-indexeddb-versioning", 1);first.onsuccess = () => { const db1 = first.result; db1.onversionchange = () => db1.close(); const second = indexedDB.open("cf-demo-indexeddb-versioning", 2); second.onblocked = () => console.log("waiting for db1"); second.onupgradeneeded = (event) => { console.log(event.oldVersion, second.result.version); second.transaction.objectStore("notes").createIndex("tag", "tag"); };};Schema changes happen only in onupgradeneeded. If one connection is still open, the upgrade can be blocked until that connection closes after versionchange.
Real apps usually listen for db.onversionchange and close the old connection, then ask the user to reload. They also handle openRequest.onblocked so the upgrade is not silently stuck.
Promise wrappers
ASYNC/AWAITIndexedDB is event-based. A request has onsuccess and onerror; a transaction has oncomplete, onabort, and onerror. Small wrappers let you use the Promisification and async & await lessons without hiding the transaction rules.
function promisifyRequest(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });} function txDone(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error); tx.onerror = () => reject(tx.error); });} async function saveBook(db, book) { const tx = db.transaction("books", "readwrite"); const id = await promisifyRequest(tx.objectStore("books").add(book)); await txDone(tx); return id;}function promisifyRequest(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });} function txDone(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error); tx.onerror = () => reject(tx.error); });} async function saveBook(db, book) { const tx = db.transaction("books", "readwrite"); const id = await promisifyRequest(tx.objectStore("books").add(book)); await txDone(tx); return id;}The wrapper does not make IndexedDB synchronous; it only converts request events into promise settlement so your code can read top to bottom.
idb package these patterns nicely. The lesson writes tiny helpers so you can see the rule.Libraries such as idb provide polished promise wrappers and convenience helpers. They are excellent in production. Learning the tiny version first helps you understand why awaiting IndexedDB requests is fine, while awaiting an unrelated timer inside a transaction is a trap.
Where you’ll use this
IndexedDB shines when the browser needs a local source of truth. An offline notes app might store drafts in a notes store, attach a updatedAt index for sync, and keep attachments as Blobs. A shop might cache a product catalog and queue checkout attempts while the network is unreliable.
async function queueDraft(db, draft) { const tx = db.transaction(["drafts", "syncQueue"], "readwrite"); const drafts = tx.objectStore("drafts"); const queue = tx.objectStore("syncQueue"); const key = await promisifyRequest(drafts.put(draft)); await promisifyRequest(queue.add({ type: "draft", key, createdAt: new Date() })); await txDone(tx);}The important design choice is the transaction boundary: saving the draft and queueing the sync job belong together. If one fails, the app should not pretend the other happened.
Common misconceptions
- “IndexedDB is just localStorage with objects.” No. It is asynchronous, transactional, indexed, and structured-clone based.
- “Opening returns the database.” No. Opening returns a request; the connection appears later on
request.result. - “I can create stores whenever I want.” No. Schema changes belong in
onupgradeneeded. - “A transaction waits while I await anything.” No. Awaiting non-IDB work can let the transaction finish.
- “Indexes store duplicate data I must keep in sync.” No. The browser maintains indexes for a store.
- “Structured clone stores functions.” No. Functions and DOM nodes throw
DataCloneError.
| Feature | IndexedDB | localStorage/sessionStorage |
|---|---|---|
| Data shape | Structured values: objects, Dates, Maps, Blobs | Strings only; JSON is your job |
| API style | Asynchronous requests and transactions | Synchronous getters and setters |
| Lookup | Primary keys, indexes, ranges, cursors | Only by string key |
| Best for | Many records, offline apps, files, sync queues | Small settings and simple flags |
Practice exercises
5 EXERCISESUse the model to remember the difference between new-only and replace-or-insert writes.
const existing = new Map([[1, "Ada"]]);
const add = existing.has(1) ? "ConstraintError" : "stored";
existing.set(1, "Grace");
console.log(add + " / " + existing.get(1));const existing = new Map([[1, "Ada"]]);
const add = existing.has(1) ? "ConstraintError" : "stored";
existing.set(1, "Grace");
console.log(add + " / " + existing.get(1));The model prints ConstraintError / Grace: new-only add failed, then replacement won.
A store saves objects shaped like { id: 7, title: "Draft" }. Which option makes IndexedDB read the key from id?
Use keyPath: id when the key lives on the object itself.
In the transfer lab, the transaction debits Ada and then aborts before crediting Grace. How many balances should change?
Zero balances should change. A transaction either commits the whole transfer or aborts it.
Use this small array model to predict what an IDBKeyRange.bound(2020, 2021) style query would include.
const years = [2019, 2020, 2021, 2024];
const matches = years.filter((year) => year >= 2020 && year <= 2021);
console.log(matches.join(", "));const years = [2019, 2020, 2021, 2024];
const matches = years.filter((year) => year >= 2020 && year <= 2021);
console.log(matches.join(", "));A closed bounded range from 2020 to 2021 matches 2020, 2021.
You want to write const book = await helper(store.get(1));. What helper from the lesson turns the request into a promise?
promisifyRequest resolves on request.onsuccess and rejects on request.onerror. Pair it with txDone for transaction completion.
Check your understanding
7 QUESTIONSQuestion 1 of 7What does
indexedDB.open(name, version)return immediately?Choose an answer to see the explanation.
Question 2 of 7What does this model print?
Read the code, then predictconst existing = new Map([[1, "Ada"]]); const add = existing.has(1) ? "ConstraintError" : "stored"; existing.set(1, "Grace"); console.log(add + " / " + existing.get(1));Choose an answer to see the explanation.
Question 3 of 7When may code create or delete object stores and indexes?
Choose an answer to see the explanation.
Question 4 of 7What is the safest description of a readwrite transaction?
Choose an answer to see the explanation.
Question 5 of 7What years does this bounded range model print?
Read the code, then predictconst years = [2019, 2020, 2021, 2024]; const matches = years.filter((year) => year >= 2020 && year <= 2021); console.log(matches.join(", "));Choose an answer to see the explanation.
Question 6 of 7Why is
await new Promise(resolve => setTimeout(resolve, 0))dangerous inside a transaction?Choose an answer to see the explanation.
Question 7 of 7Which values can IndexedDB store with structured clone?
Choose an answer to see the explanation.
Key takeaways
- Open returns a request; schema setup happens in
onupgradeneeded; success gives you a connection. - Object stores hold records, keys identify records, and indexes provide additional lookup paths.
- Transactions are all-or-nothing and auto-commit when their request queue empties.
- Key ranges, cursors, and
getAlllet you query without loading everything blindly. - Promise wrappers make code pleasant, but they do not change IndexedDB’s event-loop lifetime rules.
Final definition: IndexedDB is the browser’s transactional, indexed, structured-data database for apps that need more than string storage.
Up next: Cache API & file storage.