IndexedDB
Store structured data in the browser with IndexedDB: object stores, keys, transactions, indexes, cursors, version upgrades, and small promise wrappers.
- 01Plan a browser databaseChoose the object stores, keys, and indexes that hold your app's records.
- 02Use transactions safelyGroup reads and writes so they all succeed or all fail, and keep a transaction from finishing too early.
- 03Use await with IndexedDBTurn requests and transactions into promises, and know which rules still apply.
A real database in the browser
IndexedDB is a database that is built into the browser. A database is a place where a program keeps a lot of data in order, so it can find that data again later. IndexedDB keeps its data on the user's device. The data is still there after the page closes.
You may know localStorage. It can only store strings. IndexedDB can store real JavaScript values, such as objects, arrays, and dates.
IndexedDB is asynchronous. That means you ask for something, your code keeps running, and the answer arrives later as an event. So a big read or write does not freeze the page.
Apps use IndexedDB to keep many records between visits. For example:
- drafts the user hasn't sent yet,
- messages waiting to be sent when the network comes back,
- a product list to show while offline,
- files the user made, such as photos.
IndexedDB also works in web workers. A web worker is a script that runs in the background, away from the page, so heavy storage work doesn't slow the page down.
IndexedDB is a database in the browser. Each website gets its own databases, and other websites can't read them. (More exactly, each origin gets its own: an origin is the scheme, host, and port of a web address.) You send a request, and the result comes back later in an event.
It has these parts:
- A database holds all the data. You open it by its name.
- An object store holds records of one kind, such as
books. - A record is one saved value, such as one book object.
- A key is the value that names one record, such as its
id. - An index is a second way to find records, such as by author.
- A transaction is a group of reads and writes that succeed or fail together.
Picture a filing cabinet that belongs to one website. The database is the cabinet. Each object store is a drawer. Each record is a folder in a drawer, and the key is the label on the folder. An index is a set of tabs sorted by author or ISBN. The tabs help you find folders without opening every one.
- 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 saved record
- In real life: The label on a folder
- In JavaScript: The key, such as the record's
id - In real life: Tabs that sort the folders by author
- In JavaScript: An index, such as
author
This lesson builds IndexedDB one part at a time:
- open a database and make an object store,
- group reads and writes in a transaction,
- find records with an index,
- change the database's layout with a new version,
- write two small helpers so you can use
await.
Databases & object stores
INTERACTIVEYou start with indexedDB.open(name, version). The name picks the database. The version is a whole number that says which layout of the database you want.
open does not give you the database. It gives you a request right away: an object of type IDBOpenDBRequest that tells you later what happened. To hear about it, you put a function in the request's matching property, such as request.onsuccess. The browser then fires these events on the request:
upgradeneededruns first, if the database is new or older than the version you asked for. This is where you create object stores.successruns when the database is ready. Nowrequest.resultis the open database.errorruns if opening fails.blockedruns if another open tab is in the way.
open() gives back a request at once. Later, upgradeneeded runs if the database is new, and then success says the database is ready.An object store holds records. Every record has a key: a value that names it, like a number on a locker. You choose how keys are made when you create the store:
keyPath: "id"means "read the key from each record'sidproperty".- With no
keyPath, you pass the key yourself, as a second argument:add(value, key). This is called an out-of-line key. autoIncrement: truemakes the store count up for you: 1, 2, 3, and so on.
A key can be a number, a string, a Date, an array, or binary data (raw bytes).
id, so the books store makes key 1 and writes it into id. Later, store.get(1) finds the book by that key.Step through the code below. Choose a first visit or a return visit, then press Step and watch which handlers run.
This replay shows the order of IndexedDB's events; it doesn't open a real database. The playgrounds below use the real one, in the same order: open, the upgrade on a first visit, 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("notes-app", 1);
request.onupgradeneeded = () => {
const db = request.result;
const notes = db.createObjectStore("notes", { keyPath: "id", autoIncrement: true });
notes.createIndex("updatedAt", "updatedAt");
};
request.onsuccess = () => console.log("notes store is ready");
request.onerror = () => console.error(request.error.name);This is a second small database, for a notes app. Line 1 opens version 1 of a database called notes-app. Lines 2 to 6 are the upgrade handler. It is the only place where you can create object stores and indexes.
Line 4 creates the notes store. Its keyPath reads each note's id, and autoIncrement makes an id when a new note has none. Line 5 adds an index on updatedAt, so the app can also find and sort notes by the time they changed. Line 7 prints a message when the database is ready. Line 8 prints the error's name if opening failed.
In the playground below, press Add book first. Then press Duplicate add, and then Put id 1.
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 the cf-demo-indexeddb database and makes it again. When you leave the page, it is deleted.
books store reads keys from id and counts up when a book has none. Press Add book, then Duplicate add, then Put id 1: add fails when the key exists, and put replaces the record.The last two buttons show two ways to save a record:
addsaves a new record only. If a record with that key already exists, the request fails with aConstraintError.putsaves the record either way. If the key is new, it adds the record. If the key exists, it replaces the old record.
So use put to save changes. Use add when a second record with the same key would be a mistake.
IndexedDB saves a copy of each value. It makes the copy with structured clone, the browser's built-in way to copy data. Unlike JSON, it keeps dates as dates. It can copy dates, Maps, arrays, typed arrays, and Blobs (chunks of file data).
It can't copy functions or DOM nodes (the elements on the page). Saving one throws a DataCloneError. So save plain data, not page elements or code.
const note = {
title: "Tea",
savedAt: new Date(),
labels: new Map([["kind", "draft"]]),
};
store.put(note);
store.put({ title: "Not data", save: () => "later" });
// The second request fails with DataCloneError.Lines 1 to 5 make a note with a string, a Date, and a Map. Line 6 saves it, because all three can be copied. Line 7 tries to save an object that holds a function. A function is code, not data, so that request fails with DataCloneError.
const request = store.add({ id: 1, title: "Draft" });
request.onsuccess = () => console.log("saved");
request.onerror = (event) => {
event.preventDefault();
console.log(request.error.name);
};Each request reports its own error. Line 1 tries to add a record with id: 1. If that key is already taken, the error event fires instead of success.
By default, a failed request cancels its whole transaction: the group of work it belongs to. (The next section explains transactions.) Line 4 calls event.preventDefault() to stop that, so the rest of the work can still be saved. Line 5 prints the error's name, such as ConstraintError. Handling the error means the user isn't left with a save that failed without a word.
Transactions
LABA transaction is a group of reads and writes that succeed or fail together. Every request belongs to one. To start a transaction, you name the object stores it may use and pick a mode:
readonlycan only read.readwritecan read and change data.versionchangeis a special mode the browser uses during an upgrade. You don't start it yourself.
You move money from Ada to Grace. You don't want the money to leave Ada's account without reaching Grace's. So the teller does both steps in one visit. If something goes wrong, the teller tears up the papers, and neither step counts.
- In real life: Walk up to one teller
- In JavaScript: Start one transaction
- In real life: Take money out and put it in during the same visit
- In JavaScript: Make related requests in it
- In real life: The teller stamps the papers as done
- In JavaScript:
transaction.oncompleteruns: the changes are saved - In real life: Something goes wrong, and the papers are torn up
- In JavaScript:
abortundoes the writes
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.Run transfer takes 10 from Ada and gives 10 to Grace in one readwrite transaction. With Abort midway on, the transaction aborts after the first step, and that step is undone too. Try inactive trap waits for a timer inside a transaction, so the transaction finishes before the next request.
TransactionInactiveError: a request made after its transaction has finished.A transaction finishes by itself, and saves its work for good, as soon as both of these are true:
- it has no requests still waiting, and
- your code has handed control back to the browser, for example at an
await.
So if you await something that is not an IndexedDB request, such as a timer or a fetch, the transaction can finish while you wait. In the lab's code, line 5 waits for a timer. By line 7 the transaction has already finished, so store.put throws a TransactionInactiveError. Press Try inactive trap to see it.
The table lists what a transaction can tell you. Read each row as: when this happens, what it means, and what to do.
| Event or method | What it means | What to do |
|---|---|---|
oncomplete | All requests finished, and the changes are saved. | Update the page. Close the database if you are done with it. |
onabort | The transaction was cancelled, or a request failed and nothing handled it. No changes were saved. | Tell the user nothing changed. Read transaction.error to see why. |
onerror | A request failed, and its error reached the transaction. | Handle the error, or call abort() on purpose. |
commit() | This method asks to save right away, when you will add no more requests. Newer browsers have it. | You may use it to finish sooner. Your code works the same without it. |
Indexes & cursors
QUERYThe key is the main way to find a record. An index gives you another way: it sorts the same records by one of their properties. The browser keeps each index up to date when records change. In the playground below, the books store has two indexes:
authoris not unique, because one author can write many books.isbnis unique, because no two books share an ISBN. Saving a second book with the same ISBN fails.
author index sorts the books by author. Asking it for "Ada Lovelace" gives back both of Ada's books without checking every record.To ask for only some keys, you pass a key range: an IDBKeyRange object that says which keys you want.
IDBKeyRange.only(value)means exactly this key.IDBKeyRange.bound(low, high)means every key fromlowtohigh.IDBKeyRange.lowerBound(value)means this key and every key after it.
Then you choose how to read the matching records:
getAll(range, count)gives them all at once, as an array, but no more thancountof them.openCursor(range, direction)gives a cursor, which points at one record at a time. Callcursor.continue()to move to the next record.
const tx = db.transaction("notes", "readonly");
const notes = tx.objectStore("notes");
const recent = notes.index("updatedAt");
const range = IDBKeyRange.lowerBound(new Date("2026-01-01"));
const request = recent.getAll(range, 10);
request.onsuccess = () => console.log(request.result.length);Line 1 starts a readonly transaction. Line 3 picks the updatedAt index, which sorts the notes by the time they changed. Line 4 makes a range: every date from 1 January 2026 on. Line 5 asks for no more than 10 matching notes, so the result can't grow too big. Line 6 prints how many came back.
Use a cursor instead when you want to handle one note at a time, stop early, or work through a very long list.
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();};Each button asks the author index for some books. A key range picks which authors match. A cursor goes through the books one at a time. getAll(range, count) gives back no more than count books at once.
author index is not unique, so Ada can have two books. The isbn index is unique, so saving two books with the same ISBN would fail.- One place called
recipe-appthat holds all of the app's saved data - A
booksgroup that holds one record per book - Find every book by one
authorwithout checking every record - Take one item out of stock and add an order, both or neither
- One place called
offline-readerthat you open by name - An
accountsgroup with one record per user, keyed by username - Look up a book by its
isbn, which no two books share - Move 10 credits from Ada to Grace, or move none at all
Sort each job into the part of IndexedDB that handles it.
Versioning
UPGRADEThe second argument to indexedDB.open is the version number. To change the layout of a database, such as adding an object store or an index, you open it with a higher version. The browser then fires upgradeneeded.
Only inside that handler can you create or delete object stores and indexes. The handler runs inside a special transaction with the mode versionchange. Each open copy of the database, usually one per tab, is called a connection.
You can't add a new drawer to a cabinet while people are still filing papers in it. Everyone has to step away first. Then the remodel can start.
- In real life: The old cabinet layout
- In JavaScript:
oldVersion - In real life: The new cabinet layout
- In JavaScript:
newVersion/db.version - In real life: Add a drawer during the remodel
- In JavaScript:
createObjectStoreinonupgradeneeded - In real life: Everyone must step away first
- In JavaScript: Open tabs get
versionchange; the upgrade may beblocked
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"); };};You can change stores and indexes only in onupgradeneeded. If another connection is still open, the upgrade is blocked until that connection closes. The open connection gets versionchange to ask it to close.
In the playground's code, line 6 opens version 2 while the first connection is still open. Line 7 logs a message if that upgrade is blocked. Line 4 is how the first connection steps aside: when it gets versionchange, it closes. Then lines 8 to 10 run the upgrade and add the tag index.
Real apps handle both sides:
- The old tab listens for
versionchangeon its database, withdb.onversionchange. It closes its connection and asks the user to reload the page. - The new tab listens for
blockedon its open request, withopenRequest.onblocked. Then it can tell the user to close other tabs, instead of waiting without a word.
Promise wrappers
ASYNC/AWAITIndexedDB tells you about results with events, not promises. A request fires success or error. A transaction fires complete, abort, or error.
Two small helper functions can turn these events into promises. Then you can use await, as in the Promisification and async & await lessons. The transaction rules stay the same.
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;}- Lines 1 to 6:
promisifyRequestreturns a promise. It resolves withrequest.resultwhen the request succeeds, and rejects withrequest.errorwhen it fails. - Lines 8 to 14:
txDonereturns a promise that resolves when the transaction completes. It rejects when the transaction aborts or fails. - Lines 16 to 21:
saveBookuses both. It waits for theaddrequest to get the new key. Then it waits for the transaction to finish, so it reports success only after the book is really saved.
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 helpers don't make IndexedDB synchronous. They only turn a request's events into a promise, so your code reads from top to bottom.
idb give you helpers like these. This lesson writes small ones so you can see how they work.Libraries such as idb give you ready-made helpers like these, with more features. They are a good choice for real apps. Writing the small version yourself shows why you may await an IndexedDB request inside a transaction, but not a timer. The request keeps the transaction busy. The timer doesn't.
Where you’ll use this
IndexedDB is useful when the browser has to keep the app's data itself, for example while offline. Two examples:
- An offline notes app keeps drafts in a
notesstore. AnupdatedAtindex finds what changed since the last sync, and attached files are saved asBlobs. - A shop saves its product list, and keeps a list of orders to send when the network works again.
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);}Line 2 starts one readwrite transaction over two stores, drafts and syncQueue. Line 6 saves the draft and gets its key. Line 7 adds a job to the queue that says "send this draft to the server later". Line 8 waits until both are saved.
Both writes are in the same transaction on purpose. If one fails, neither is saved. So the app never has a queue job for a missing draft, or a draft that will never be sent.
const estimate = await navigator.storage.estimate();
console.log(estimate.usage, estimate.quota);
const alreadyPersistent = await navigator.storage.persisted();
const granted = alreadyPersistent || await navigator.storage.persist();
console.log(granted ? "less likely to be evicted" : "still best effort");When a device runs low on space, the browser may delete a site's data by itself. This is called eviction. The code above checks for that risk:
- Lines 1 and 2 print how much space the site uses and how much it may use, its quota. Both numbers are rough guesses, not a promise of free space.
- Lines 3 and 4 check whether the site's data is already protected from eviction. If not,
persist()asks for it. The browser may say no. - Line 5 prints the answer. "Still best effort" means the browser keeps the data only while it has room.
Even then, data can disappear. Users can clear site data, and a private window may delete everything when it closes. So an offline app must be able to download its data from the server again.
The server should keep the main copy of important data. IndexedDB is good for drafts, offline copies, and lists of work to send later. It is not a backup. It is also not a safe place for secrets, because any JavaScript on the page can read it. Don't store passwords or long-lived secrets there.
Common misconceptions
- “IndexedDB is just localStorage with objects.” No. IndexedDB is asynchronous, groups work into transactions, finds records with indexes, and copies values with structured clone. localStorage does none of these.
- “Opening returns the database.” No.
openreturns a request. The database arrives later, inrequest.result. - “I can create stores whenever I want.” No. You can create and delete stores and indexes only in
onupgradeneeded. - “A transaction waits while I await anything.” No. If you await something that is not an IndexedDB request, the transaction can finish while you wait.
- “Indexes store duplicate data I must keep in sync.” No. The browser updates every index of a store for you when its records change.
- “Structured clone stores functions.” No. Functions and DOM nodes throw
DataCloneError.
The table compares IndexedDB with localStorage and sessionStorage. Each row answers one question about both.
| Question | IndexedDB | localStorage and sessionStorage |
|---|---|---|
| What can it store? | Objects, arrays, dates, Maps, Blobs, and other values it can copy. | Only strings. You turn objects into JSON text yourself. |
| How do you call it? | Asynchronously: you send a request, and the result comes later in an event. | Synchronously: the answer comes back at once, and the page waits for it. |
| How do you find data? | By key, by index, by key range, or one record at a time with a cursor. | Only by its string key. |
| What is it good for? | Many records, offline apps, files, and lists of work to send later. | Small settings and simple on/off choices. |
Practice exercises
5 EXERCISESThis small model uses a Map in place of a store. Key 1 already holds "Ada". Line 2 acts like add, and line 3 acts like put. What does line 4 print?
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));It prints ConstraintError / Grace. The add failed because key 1 was taken. Then the replace changed the name to Grace.
A store saves objects like { id: 7, title: "Draft" }. Which option makes IndexedDB read each key from id?
Use keyPath: "id" when the key is a property of the object itself.
In the transactions lab, turn Abort midway on and press Run transfer. The code takes 10 from Ada, then aborts before it gives 10 to Grace. How many balances change?
Zero. The transaction aborted, so the 10 taken from Ada is put back, and Grace never gets it. Neither balance changes.
This array model works like IDBKeyRange.bound(2020, 2021): it keeps every year from 2020 to 2021. What does it print?
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(", "));The range keeps every year from 2020 to 2021, including both ends, so it prints 2020, 2021.
You want to write const book = await helper(store.get(1));. Which helper from this lesson turns the request into a promise?
promisifyRequest resolves when the request's success event fires and rejects on error. Use it with txDone, which waits for the transaction to finish.
Check your understanding
7 QUESTIONSQuestion 1 of 7What does
indexedDB.open(name, version)give back right away?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 can your code create or delete object stores and indexes?
Choose an answer to see the explanation.
Question 4 of 7Which sentence best describes a
readwritetransaction?Choose an answer to see the explanation.
Question 5 of 7Which years does this 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))a problem inside a transaction?Choose an answer to see the explanation.
Question 7 of 7Which values can IndexedDB copy and save?
Choose an answer to see the explanation.
Key takeaways
openreturns a request. You create stores inonupgradeneeded, andsuccessgives you the database.- Object stores hold records. A key names each record, and an index is a second way to find them.
- A transaction saves all of its changes or none. It finishes by itself when no requests are waiting.
- Key ranges, cursors, and
getAlllet you read only the records you need. - Promise helpers make the code easier to read, but they don't change when a transaction finishes.
Final definition: IndexedDB is a database in the browser that saves copies of JavaScript values, finds them by key or index, and groups changes into transactions.
Up next: Cache API & file storage.