JavaScript IndexedDB Explained: The Browser’s Real Database

Link copied
JavaScript IndexedDB Explained: The Browser’s Real Database

JavaScript IndexedDB Explained: The Browser’s Real Database

JS Tutorial Module 11: Beyond DOM Lesson 11.1

Where localStorage (Lesson 7.5) is a small synchronous key-value store, IndexedDB is the browser's actual database. Hundreds of MB to GB of capacity, async APIs, indexed queries, schema/versioning, transactions. It's what offline-first apps use to cache data, what PWAs use to persist between sessions, and what every browser-side database library (Dexie, idb, RxDB) wraps.

This lesson covers the IndexedDB mental model, the verbose native API, and the practical idb library that hides 80% of the verbosity. By the end you'll know when to reach for IndexedDB and how to use it without writing 200 lines of boilerplate.

When IndexedDB earns its keep #

Reach for IndexedDB when:

  • The data is more than a few MB (localStorage's 5 MB limit is too tight)
  • You need to query by something other than the primary key (indexes on fields)
  • You're caching API responses or building an offline-first experience
  • You need to store Blobs, Files, or other binary data
  • You can't block the main thread (IndexedDB is async; localStorage is sync)

Skip IndexedDB when:

  • A few simple preferences fit in localStorage
  • The data is short-lived and re-fetchable on every page load
  • A server-side session/database is the source of truth and you don't need offline

The mental model #

IndexedDB is an object database, not relational. The main concepts:

  • Database — a named storage area, scoped to your origin.
  • Object store — like a table; holds keyed objects. Configured at upgrade time.
  • Key — the primary identifier of each record. Can be a property on the object (keyPath), or auto-incremented.
  • Index — a queryable secondary key on a property.
  • Transaction — a unit of work. Reads or writes. Auto-commits when the JavaScript task ends.
  • Cursor — for iterating over many records (instead of fetching all into memory).
  • Versioning — schemas evolve via versioned upgrades.

This is a more powerful model than "key/value blob" — but the native API for using it is famously clunky.

The raw API in 20 lines #

Opening a database, creating a store, putting a record, reading it back:

const request = indexedDB.open('myApp', 1);

request.onupgradeneeded = (e) => {
  const db = e.target.result;
  db.createObjectStore('users', { keyPath: 'id' });
};

request.onsuccess = (e) => {
  const db = e.target.result;

  const tx = db.transaction('users', 'readwrite');
  tx.objectStore('users').put({ id: 1, name: 'Ada' });

  const readTx = db.transaction('users');
  const getRequest = readTx.objectStore('users').get(1);
  getRequest.onsuccess = () => console.log(getRequest.result);
};

Notice the event-callback shape. IndexedDB predates Promises — every operation returns a Request object that fires onsuccess and onerror. Useful to read once, painful to write.

The modern way: the idb library #

The community standard is idb by Jake Archibald (~1 KB gzipped). It wraps the native API in Promises:

import { openDB } from 'idb';

const db = await openDB('myApp', 1, {
  upgrade(db) {
    db.createObjectStore('users', { keyPath: 'id' });
  },
});

await db.put('users', { id: 1, name: 'Ada' });
const user = await db.get('users', 1);
console.log(user); // { id: 1, name: 'Ada' }

Four lines instead of fifteen, and it reads top-to-bottom. Use idb (or a higher-level library like Dexie) for any production code. The rest of this lesson uses idb syntax — the concepts apply to both.

Schema versioning #

Databases evolve. Add a new field, add an index, drop a store — that's a schema migration:

const db = await openDB('myApp', 3, {
  upgrade(db, oldVersion, newVersion, tx) {
    if (oldVersion < 1) {
      db.createObjectStore('users', { keyPath: 'id' });
    }
    if (oldVersion < 2) {
      const store = tx.objectStore('users');
      store.createIndex('by-email', 'email', { unique: true });
    }
    if (oldVersion < 3) {
      db.createObjectStore('posts', { keyPath: 'id', autoIncrement: true });
    }
  },
});

The upgrade callback runs only when the version number increases. Always nest changes in if (oldVersion < N) blocks so users on any older version get every step.

This is one of IndexedDB's strengths — versioning is built in. Compare with localStorage, where any schema change requires manual migration scripts and key-by-key cleanup.

Indexes — querying by fields #

Without an index, you can only look up records by their primary key. With one, you can query by any property:

// In upgrade:
store.createIndex('by-email', 'email', { unique: true });
store.createIndex('by-role',  'role',  { unique: false });

// In queries:
const byEmail = db.transaction('users').store.index('by-email');
const user = await byEmail.get('ada@example.com');

const byRole = db.transaction('users').store.index('by-role');
const admins = await byRole.getAll('admin');

Indexes can also be on a function or on multi-part composite keys (['role', 'createdAt']).

Cursors — iterating without loading everything #

When a query might return thousands of records, fetching them all is wasteful. Cursors iterate one record at a time:

const tx = db.transaction('users');
let cursor = await tx.store.openCursor();
let count = 0;
while (cursor) {
  if (cursor.value.active) count++;
  cursor = await cursor.continue();
}
console.log('Active users:', count);

Useful for streaming exports, building search indexes, computing aggregates without buffering.

Transactions #

All IndexedDB operations happen inside a transaction. A transaction:

  • Locks the listed stores while it's active.
  • Has a mode: 'readonly' (default) or 'readwrite'.
  • Auto-commits when the current JS task ends with no pending requests.
  • Can be aborted with tx.abort() to roll back changes.
const tx = db.transaction(['users', 'posts'], 'readwrite');
await tx.objectStore('users').put({ id: 1, name: 'Ada' });
await tx.objectStore('posts').put({ id: 1, userId: 1, title: 'Hi' });
await tx.done;  // wait for the transaction to commit

With multiple operations, wait on tx.done at the end. Either everything commits, or — if any operation fails — the whole transaction rolls back.

The auto-commit trap #

A transaction commits as soon as the JavaScript task it lives in ends with no further requests pending. This means you can't await an unrelated Promise mid-transaction:

// BUG — fetch finishes after the transaction has committed
const tx = db.transaction('users', 'readwrite');
const data = await fetch('/api/users').then(r => r.json());
await tx.store.put(data);  // throws — transaction is already inactive

Fetch all the data first, then open the transaction:

const data = await fetch('/api/users').then(r => r.json());
const tx = db.transaction('users', 'readwrite');
await tx.store.put(data);
await tx.done;

This is the single most common IndexedDB bug. Once you know about it, it's avoidable.

Storing binary data #

IndexedDB happily stores Blobs and ArrayBuffers — useful for caching images, audio, PDFs:

const response = await fetch('/big-image.jpg');
const blob = await response.blob();
await db.put('files', { id: 'avatar', blob, type: blob.type });

// Later:
const record = await db.get('files', 'avatar');
const url = URL.createObjectURL(record.blob);
img.src = url;

No size limit per record (within the quota). Browsers cache massive PWAs (Spotify, Discord, Figma) this way.

Quota and persistence #

Each origin gets a quota. Modern browsers allocate generously (often gigabytes) but they can also clear data under pressure.

To opt into more durable storage:

if ('persist' in navigator.storage) {
  const granted = await navigator.storage.persist();
  console.log('persistent:', granted);
}

const { quota, usage } = await navigator.storage.estimate();
console.log(`${usage} of ${quota} bytes used`);

persist() requests guaranteed-not-evicted status — usually granted to sites the user has bookmarked, installed as a PWA, or interacted with extensively.

A worked example: simple caching layer #

import { openDB } from 'idb';

const dbPromise = openDB('api-cache', 1, {
  upgrade(db) {
    db.createObjectStore('responses', { keyPath: 'url' });
  },
});

export async function cachedFetch(url, ttlMs = 60_000) {
  const db = await dbPromise;
  const cached = await db.get('responses', url);
  if (cached && Date.now() - cached.at < ttlMs) {
    return cached.data;
  }
  const res = await fetch(url);
  const data = await res.json();
  await db.put('responses', { url, data, at: Date.now() });
  return data;
}

30 lines, you have an offline-aware fetch wrapper that survives browser restarts. Service Workers (Lesson 11.4) integrate even more deeply with this pattern.

When to reach for higher-level libraries #

  • idb — thin Promise wrapper. Use for any direct IndexedDB code.
  • idb-keyval — a pure KV (key/value) API for the simplest cases. "localStorage but async and bigger."
  • Dexie.js — query builder, hooks, relationships, observable queries. Reads like an ORM.
  • RxDB — full reactive database with replication (to a server, between tabs, between devices).

For most apps, idb or idb-keyval is enough. Move up the stack when you need queries by multiple fields, schema migrations, or sync.

A summary #

  • IndexedDB is the real browser database — async, indexed, transactional, gigabyte-capable.
  • The native API is verbose and event-callback shaped. Use idb (or Dexie) for production.
  • Stores hold keyed objects; indexes enable queries by other fields.
  • Versioning is built in — schema migrations in the upgrade callback.
  • Transactions auto-commit at end of task. Don't await unrelated work mid-transaction.
  • Binary data (Blobs) is first-class.
  • navigator.storage.persist() opts into eviction protection.

What's next #

Lesson 11.2 covers the observer APIsIntersectionObserver, ResizeObserver, MutationObserver — the modern event-driven primitives that replaced scroll handlers, resize listeners, and DOM-mutation polling.

Try it yourself #

The "transaction commits at end of task" trap is the easiest one to feel. Predict what happens:

YouWhat’s the bug?
const tx = db.transaction('store', 'readwrite');
await tx.store.put({ id: 1, name: 'Ada' });
const data = await fetch('/api/data').then(r => r.json());
await tx.store.put({ id: 2, data });
ClaudeThe second put will throw InvalidStateError: The transaction is inactive.

Between the two puts, fetch is awaited. While JavaScript is waiting on the network, the original task completes — and IndexedDB auto-commits the transaction. By the time fetch resolves and the second put runs, the transaction is closed.

Fix: fetch the data before opening the transaction, then do both puts back-to-back.

The auto-commit-at-end-of-task rule is the #1 trap for newcomers to IndexedDB. Once you know it, you organize code around it naturally.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *