EthicsComputerCourses
Join training +91 90532 10052

IndexedDB: A Database in the Browser

JavaScript · Lesson 52 of 74 Advanced 8 min read Updated

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:

WordMeaningLike in SQL
DatabaseA named container for your site, with a version numberA database
Object storeA collection of records, each with a unique keyA table
Key / keyPathThe property that identifies each record, such as skuThe primary key
IndexA second way to look records up, such as by categoryAn index
TransactionA group of reads and writes that succeed or fail togetherA transaction
RequestOne operation; it reports its result later with a success or error eventA 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:

JavaScriptRun it yourself
// 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();
Output
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() and delete(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:

JavaScriptRun it yourself
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();
Output
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:

JavaScriptRun it yourself
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();
Output
[ '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:

JavaScript
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:

JavaScript
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. createObjectStore throws 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 put fails 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 a complete event.
  • Indexes, IDBKeyRange and 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() and persist() 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
JavaScript
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()?

2. Why does a stored Date come back as a real Date object?

3. What happens if you await fetch(...) in the middle of a transaction?

4. Which tool returns every product priced between ₹100 and ₹500 using a price index?

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