IndexedDB: A Database in the Browser
localStorage is perfect for a theme setting or a small draft, but it has hard limits: it stores only strings, it holds only a few megabytes, and every read and write blocks the main thread. Apps that work with real amounts of data, such as an offline product catalogue, a field-sales app that saves orders without a network, or a photo editor that keeps images between visits, need something stronger.
That is IndexedDB: a database built into every modern browser. It stores real JavaScript objects (including dates, arrays, Blobs and files), can hold far more than localStorage, searches quickly with indexes, and works asynchronously so the page stays smooth. It is also available inside Web Workers and service workers. In this lesson you will open a database, add, read, change and delete records, search with indexes, and handle version upgrades.
The main ideas
IndexedDB has its own vocabulary. Learn these six words and the rest follows:
| Word | Meaning | Like in SQL |
|---|---|---|
| Database | A named container for your site, with a version number | A database |
| Object store | A collection of records, each with a unique key | A table |
Key / keyPath | The property that identifies each record, such as sku | The primary key |
| Index | A second way to look records up, such as by category | An index |
| Transaction | A group of reads and writes that succeed or fail together | A transaction |
| Request | One operation; it reports its result later with a success or error event | A query |
Each website (each origin) has its own databases. Another website cannot read them, and the user can clear them, so treat IndexedDB as a fast local copy, not the only place important data lives.
Opening a database
indexedDB.open(name, version) opens a database, creating it if needed. If the database is new, or the version number you ask for is higher than the stored one, the browser fires an upgradeneeded event first. That event is the only place where you may create or delete object stores and indexes.
The original API uses events, not promises. A tiny helper turns any request into a promise, so you can use await:
// Turn an IndexedDB request into a promise
function done(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function openShopDb() {
const request = indexedDB.open("shop-demo", 1);
// Runs only when the database is new or the version number goes up
request.onupgradeneeded = () => {
const db = request.result;
const products = db.createObjectStore("products", { keyPath: "sku" });
products.createIndex("byCategory", "category");
products.createIndex("byPrice", "price");
};
return done(request);
}
await done(indexedDB.deleteDatabase("shop-demo")); // start fresh on every run
const db = await openShopDb();
console.log(db.name, "version", db.version, [...db.objectStoreNames]);
db.close();shop-demo version 1 [ 'products' ]
{ keyPath: "sku" } says every product object carries its own key in its sku property. The two indexes let you search by category and by price later. The example deletes the database first only so that every run starts clean; a real app would never do that.
Adding, reading, changing and deleting
All reading and writing happens inside a transaction. Create one with db.transaction(storeName, mode), where the mode is "readonly" (the default) or "readwrite". Then call methods on the object store:
put(record)adds a record, or replaces the one with the same key.add(record)adds a record, but fails if the key already exists.get(key),getAll(),count()anddelete(key)do what their names say.
A transaction finishes by itself once you stop giving it work. Its complete event means every write has been saved. A second small helper waits for that:
function done(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function finished(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
function openShopDb() {
const request = indexedDB.open("shop-demo", 1);
request.onupgradeneeded = () => {
const products = request.result.createObjectStore("products", { keyPath: "sku" });
products.createIndex("byCategory", "category");
products.createIndex("byPrice", "price");
};
return done(request);
}
await done(indexedDB.deleteDatabase("shop-demo"));
const db = await openShopDb();
// Write: one transaction for many records
const tx = db.transaction("products", "readwrite");
const store = tx.objectStore("products");
store.put({ sku: "NB-01", name: "Notebook", category: "stationery", price: 60, tags: ["a4", "ruled"] });
store.put({ sku: "PN-07", name: "Gel pen", category: "stationery", price: 25, addedOn: new Date(2026, 9, 1) });
store.put({ sku: "BG-02", name: "School bag", category: "bags", price: 1299 });
store.put({ sku: "BT-11", name: "Water bottle", category: "kitchen", price: 349 });
await finished(tx);
// Read: get one record by its key
const read = db.transaction("products").objectStore("products");
const pen = await done(read.get("PN-07"));
console.log(pen.name, pen.addedOn instanceof Date);
console.log("Count:", await done(read.count()));
// Change and delete in one readwrite transaction
const tx2 = db.transaction("products", "readwrite");
tx2.objectStore("products").put({ ...pen, price: 30 });
tx2.objectStore("products").delete("BT-11");
await finished(tx2);
const all = await done(db.transaction("products").objectStore("products").getAll());
console.log(all.map((p) => `${p.sku} ₹${p.price}`).join(", "));
db.close();Gel pen true Count: 4 BG-02 ₹1299, NB-01 ₹60, PN-07 ₹30
Three things to notice. The Date came back as a real Date, not text, because IndexedDB copies values with the same structured clone method as structuredClone(); there is no JSON.stringify step. getAll() returns records sorted by key, which is why BG-02 comes first. And the two changes in tx2 are atomic: if one of them had failed, neither would have been saved.
Searching with indexes and key ranges
Indexes let you find records by something other than the key. index.getAll(value) returns every record with that value. For ranges, build an IDBKeyRange: bound(lower, upper), lowerBound(x), upperBound(x) or only(x). When you need records one at a time, for example to stop early or walk backwards, open a cursor:
function done(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function openShopDb() {
const request = indexedDB.open("shop-demo", 1);
request.onupgradeneeded = () => {
const products = request.result.createObjectStore("products", { keyPath: "sku" });
products.createIndex("byCategory", "category");
products.createIndex("byPrice", "price");
// Fill the new store with sample data
for (const p of [
{ sku: "NB-01", name: "Notebook", category: "stationery", price: 60 },
{ sku: "PN-07", name: "Gel pen", category: "stationery", price: 25 },
{ sku: "GS-03", name: "Geometry set", category: "stationery", price: 180 },
{ sku: "BG-02", name: "School bag", category: "bags", price: 1299 },
{ sku: "BT-11", name: "Water bottle", category: "kitchen", price: 349 },
{ sku: "LB-05", name: "Lunch box", category: "kitchen", price: 449 },
]) products.put(p);
};
return done(request);
}
await done(indexedDB.deleteDatabase("shop-demo"));
const db = await openShopDb();
const store = db.transaction("products").objectStore("products");
// Every product in one category
const stationery = await done(store.index("byCategory").getAll("stationery"));
console.log(stationery.map((p) => p.name));
// Prices from ₹100 to ₹500, sorted by price
const range = IDBKeyRange.bound(100, 500);
const midRange = await done(store.index("byPrice").getAll(range));
console.log(midRange.map((p) => `${p.name} ₹${p.price}`));
// A cursor walks records one by one, here from the most expensive down
const cursorRequest = store.index("byPrice").openCursor(null, "prev");
const top = [];
await new Promise((resolve, reject) => {
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor || top.length === 2) return resolve();
top.push(cursor.value.name);
cursor.continue(); // fires onsuccess again with the next record
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
console.log("Two most expensive:", top);
db.close();[ 'Geometry set', 'Notebook', 'Gel pen' ] [ 'Geometry set ₹180', 'Water bottle ₹349', 'Lunch box ₹449' ] Two most expensive: [ 'School bag', 'Lunch box' ]
Results from an index come sorted by the index value, and records with the same value are sorted by their key. All three stationery items share the value "stationery", so they appear in sku order: GS-03, NB-01, PN-07.
Changing the structure later: versions
Your app will grow. To add a store or an index, raise the version number and handle each old version inside upgradeneeded. The event's oldVersion tells you where the user's copy starts, so one function can bring any old copy up to date:
const request = indexedDB.open("shop-demo", 3);
request.onupgradeneeded = (event) => {
const db = request.result;
const tx = request.transaction; // the special upgrade transaction
if (event.oldVersion < 1) {
const products = db.createObjectStore("products", { keyPath: "sku" });
products.createIndex("byCategory", "category");
}
if (event.oldVersion < 2) {
tx.objectStore("products").createIndex("byPrice", "price");
}
if (event.oldVersion < 3) {
db.createObjectStore("orders", { keyPath: "id", autoIncrement: true });
}
};
request.onblocked = () => {
console.log("Please close other tabs of this site to finish the update.");
};An upgrade cannot finish while another tab still has the old version open. That tab receives a versionchange event on its database; the polite response is db.close() and a message asking the user to reload. autoIncrement: true makes the browser create numeric keys 1, 2, 3... for you.
Space, persistence and libraries
Browsers give each site a share of the free disk space, which is usually far more than localStorage allows. You can ask how much you are using, and ask the browser not to clear your data when space runs low:
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1e6).toFixed(1)} MB of about ${(quota / 1e6).toFixed(0)} MB`);
const persisted = await navigator.storage.persist(); // the browser may say no
console.log(persisted ? "Data will be kept" : "Data may be cleared when space is low");Many teams do not use the raw API directly. Small libraries such as idb wrap it in promises (much like the helpers above), and Dexie adds a friendlier query language. Knowing the raw API still matters: the concepts are the same, and the error messages in DevTools use the raw names. In Chrome DevTools, the Application panel lets you browse every database, store and record.
Common mistakes
- Creating stores outside
upgradeneeded.createObjectStorethrows anywhere else. Raise the version to change the structure. - Awaiting other work inside a transaction. The transaction closes and later requests fail. Prepare data first, then write.
- Forgetting the mode. Transactions are read-only by default, so
putfails without"readwrite". - Storing class instances and expecting methods back. Structured clone keeps data, not methods or the class. Rebuild objects after reading.
- Treating IndexedDB as permanent. Users and browsers can clear it. Keep the real copy on a server and use IndexedDB as a cache or offline queue.
Key points
- IndexedDB is an asynchronous, transactional database in the browser that stores real objects, works in workers and holds much more than
localStorage. - Create object stores and indexes only in
upgradeneeded, and raise the version number to change them. - Read and write inside transactions;
"readwrite"transactions are atomic and finish with acompleteevent. - Indexes,
IDBKeyRangeand cursors let you search by other fields, by ranges and in either direction. - Wrap requests in promises (or use a library such as idb or Dexie) so you can use
await. - Use
navigator.storage.estimate()andpersist()to understand and protect your storage.
Exercise
Create a database called orders-demo with an object store orders (key id, auto-incremented) and an index on city. Add four orders from Jaipur, Pune and Kochi with an amount in rupees. Then print the total amount of all Pune orders using the index.
Show solution
function done(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
await done(indexedDB.deleteDatabase("orders-demo"));
const open = indexedDB.open("orders-demo", 1);
open.onupgradeneeded = () => {
const orders = open.result.createObjectStore("orders", { keyPath: "id", autoIncrement: true });
orders.createIndex("byCity", "city");
};
const db = await done(open);
const tx = db.transaction("orders", "readwrite");
const orders = tx.objectStore("orders");
orders.add({ city: "Pune", amount: 1299 });
orders.add({ city: "Jaipur", amount: 450 });
orders.add({ city: "Pune", amount: 799 });
orders.add({ city: "Kochi", amount: 2100 });
const pune = await done(orders.index("byCity").getAll("Pune"));
const total = pune.reduce((sum, order) => sum + order.amount, 0);
console.log(`Pune orders: ${pune.length}, total ₹${total}`);
db.close();The byCity index finds both Pune orders, and reduce() adds their amounts: Pune orders: 2, total ₹2098. The read happens inside the same readwrite transaction, right after the writes, so it sees the new records.
Quick Quiz
1. Where can you call createObjectStore()?
Changing the structure is allowed only during an upgrade, which runs when the database is new or its version goes up.
2. Why does a stored Date come back as a real Date object?
Structured clone keeps types such as Date, Map, arrays and Blob, unlike JSON.
3. What happens if you await fetch(...) in the middle of a transaction?
Transactions close when they have no pending requests, so do unrelated async work before opening one.
4. Which tool returns every product priced between ₹100 and ₹500 using a price index?
IDBKeyRange.bound() describes a range of index values, and getAll() returns the matching records.
Learn JavaScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
