JavaScriptIndexedDB

IndexedDB

When localStorage is too small or too synchronous, the browser has a real database built in: IndexedDB. It is an asynchronous, transactional, key/value (with indexes) store that can hold hundreds of MB per origin and stores structured data — including Date, Blob, File, ArrayBuffer and Map — directly, without JSON serialisation. The native API is verbose; almost everyone uses a wrapper like idb.

What IndexedDB is good for
  • Offline-capable apps that need to keep data when the network is down.

  • Caching API responses larger than a few hundred KB.

  • Storing user-generated files — images, audio, attachments — as Blobs.

  • Local search indexes for documents, notes, mail.

  • Sync queues — actions to replay against the server when back online.

The mental model
  • Database — named, versioned, per-origin.

  • Object store — like a table, but holds JS objects keyed by a keyPath or auto-incremented key.

  • Index — a secondary lookup on a property of stored objects.

  • Transaction — every read/write happens inside a transaction; transactions auto-commit when no more requests are pending.

Opening a database

Open / upgrade

JS
function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open("notes-app", 1);

    req.onupgradeneeded = (e) => {
      const db = e.target.result;
      // Only runs when the version number is new (or on first open):
      const store = db.createObjectStore("notes", {
        keyPath: "id",
        autoIncrement: true,
      });
      store.createIndex("by_tag", "tag", { unique: false });
      store.createIndex("by_updated", "updatedAt");
    };

    req.onsuccess = () => resolve(req.result);
    req.onerror   = () => reject(req.error);
  });
}

Bumping the second argument to indexedDB.open triggers onupgradeneeded again — this is how you migrate schemas.

Reading and writing

put / get / delete

JS
function put(db, value) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("notes", "readwrite");
    const req = tx.objectStore("notes").put(value);
    req.onsuccess = () => resolve(req.result); // the key
    tx.onerror = () => reject(tx.error);
  });
}

function get(db, id) {
  return new Promise((resolve, reject) => {
    const req = db
      .transaction("notes", "readonly")
      .objectStore("notes")
      .get(id);
    req.onsuccess = () => resolve(req.result);
    req.onerror   = () => reject(req.error);
  });
}

function remove(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("notes", "readwrite");
    tx.objectStore("notes").delete(id);
    tx.oncomplete = () => resolve();
    tx.onerror    = () => reject(tx.error);
  });
}

// Usage:
const db = await openDB();
const id = await put(db, { title: "Hello", tag: "personal", updatedAt: Date.now() });
const note = await get(db, id);
await remove(db, id);
Listing with a cursor

JS
function listByTag(db, tag) {
  return new Promise((resolve, reject) => {
    const results = [];
    const tx = db.transaction("notes", "readonly");
    const index = tx.objectStore("notes").index("by_tag");
    const req = index.openCursor(IDBKeyRange.only(tag));
    req.onsuccess = (e) => {
      const cursor = e.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };
    req.onerror = () => reject(req.error);
  });
}
The idb wrapper

The native API is event-based; promisifying it as above is repetitive. The tiny idb package by Jake Archibald gives you a clean async/await surface.

With idb

JS
import { openDB } from "idb";

const db = await openDB("notes-app", 1, {
  upgrade(db) {
    const store = db.createObjectStore("notes", {
      keyPath: "id",
      autoIncrement: true,
    });
    store.createIndex("by_tag", "tag");
  },
});

const id = await db.put("notes", { title: "Hello", tag: "personal" });
const note = await db.get("notes", id);
const personal = await db.getAllFromIndex("notes", "by_tag", "personal");
await db.delete("notes", id);
Storage quota

JS
if (navigator.storage?.estimate) {
  const { usage, quota } = await navigator.storage.estimate();
  console.log(`Using ${(usage / 1e6).toFixed(1)} MB of ${(quota / 1e6).toFixed(0)} MB`);
}

// Ask the browser to make the data persistent (i.e. not auto-evicted):
const persisted = await navigator.storage.persist();

Chromium gives apps a generous share of disk (often tens of GB). Safari is much stricter and evicts after a few days of inactivity unless you call navigator.storage.persist() and the browser grants it.

Things to remember
  • Transactions auto-commit when the JS stack drains — do not await non-IndexedDB work inside a transaction or it will close on you.

  • All keys must be strings, numbers, dates, or arrays of them. They are not arbitrary objects.

  • Cross-origin scripts cannot see your IndexedDB — it is origin-scoped.

  • Private/incognito mode often disables IndexedDB persistence or gives a much smaller quota.

  • Big writes should be batched into one transaction for atomicity and speed.

When to pick what
Use `localStorage` for a handful of small preferences. Use **IndexedDB** the moment you have records to query, files to keep, or megabytes of data — and reach for `idb` so you can write `await db.put(...)` instead of nested callbacks.
Tip
Pair IndexedDB with a **Service Worker** to build an app that works offline: cache the shell with the worker, store data in IndexedDB, sync to the server when the network comes back.