BizTechLab

IDEASINNOVATIONIMPACT

Web APIs

IndexedDB

The browser's built-in asynchronous, transactional database — for when localStorage's limits become a real problem.

2 August 20268 min read

Overview

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)titletagupdatedAt
1Grocery listpersonal2026-08-01T09:15:00
2Sprint planning noteswork2026-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

beginner

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.

intermediate

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.

senior

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

localStorageIndexedDB
API styleSynchronousAsynchronous
Capacity~5–10MBHundreds of MB+
Data typesStrings onlyStructured JS values
Query capabilityBy exact key onlyBy key, index, or cursor

Related Articles