Skip to content

IndexedDB

IndexedDB is the browser's transactional, indexed object database, and the only structured storage API that works in windows, dedicated workers, shared workers and service workers alike. It stores large amounts of structured data, including Blobs, and supports atomic writes across several stores and range queries over sorted keys. In a Progressive Web App it holds everything that isn't an HTTP response: application state, user documents, sync queues, and the metadata that makes Cache Storage manageable. This page covers the data model and the spec-level rules for keys, indexes, transactions and schema upgrades, the bugs those rules cause in practice, and production patterns written with both the raw API and the idb library.

Key takeaways

  • A database belongs to one storage key (the origin, partitioned in third-party contexts), has an integer version, and contains object stores of records sorted by key. Indexes are secondary sorted views that the browser keeps up to date for you.
  • Schema changes happen only inside the versionchange transaction that runs when you open a database with a higher version. Every connection still open on the old version receives versionchange and must close, or the upgrade stays blocked.
  • Transactions auto-commit as soon as they have no pending requests and control returns to the event loop. Awaiting anything that isn't an IndexedDB request (fetch(), timers, crypto.subtle) mid-transaction makes the next request throw TransactionInactiveError.
  • Read/write transactions with overlapping scopes run one at a time in creation order, and read-only transactions can run concurrently. Batch writes into one transaction, prefer getAll() / getAllRecords() over cursors, and use durability: "relaxed" (Chrome's default since 121) for data you can re-fetch.
  • Store binary data as Blobs, never base64 strings or large ArrayBuffers, and handle NotReadableError for Blob files lost from disk (Chromium 132+).
  • The raw API is event-based. idb 8 wraps it in promises without hiding the transaction model, and Dexie 4 adds a query language and live queries. localForage hasn't had a release since August 2021.
  • Write each outbox entry in the same transaction as the local change it describes. That one rule makes offline writes crash-safe.

Where IndexedDB fits in a PWA

A PWA typically uses three or four storage systems side by side. They share one quota (see Storage Quotas & Persistence) but have very different data models:

IndexedDB Cache Storage OPFS localStorage
Data model Object stores of structured-cloned values, sorted by key, with secondary indexes Request → Response pairs Directories and files of bytes String → string
Queries Exact keys, key ranges and counts on the primary key and on indexes, cursors URL match (with ignoreSearch, ignoreVary, ignoreMethod) Path lookup Exact key
Atomicity Multi-store transactions addAll() is atomic, everything else per call createWritable() swaps one file atomically on close() None
API style Asynchronous (events, or promises with a wrapper) Promises Promises, plus synchronous handles in dedicated workers Synchronous, blocks the main thread
Window / dedicated worker / service worker ✅ / ✅ / ✅ ✅ / ✅ / ✅ ✅ / ✅ / ✅ (sync handles: dedicated workers only) ✅ / ❌ / ❌
Quota Shared origin quota Shared origin quota Shared origin quota About 5 MiB, separate
Best for App data, queues, metadata, moderate Blobs HTTP responses, app shell, runtime caches Large files, random access, SQLite Tiny, window-only flags

Rules of thumb:

  • HTTP responses belong in Cache Storage, so a fetch handler can return them unchanged. Cache Storage has no expiry or LRU of its own, so keep the facts about cached entries (when stored, last used, size) in IndexedDB. See a cache metadata store.
  • Application data (records you query, filter, sort and update) belongs in IndexedDB. Don't cache API responses as opaque JSON blobs in Cache Storage and parse them on every read. Normalize them into object stores you can index.
  • Large binary files you need to read partially or write in place (video, audio, project files, SQLite databases) belong in OPFS. Metadata about those files still fits best in IndexedDB.
  • localStorage is synchronous, main-thread only and string-only. A service worker can't read it, so it is useless for anything the offline layer needs.

For how IndexedDB fits into an offline-first architecture with conflict resolution and sync, see Offline-First Data & Sync. This page focuses on the database itself.

The data model: databases, object stores and records

flowchart TD
    Key["Storage key: https://app.example"] --> DB1["Database notes-app, version 3"]
    Key --> DB2["Database cache-meta, version 1"]
    DB1 --> S1["Object store notes, keyPath id"]
    DB1 --> S2["Object store outbox, autoIncrement"]
    DB1 --> S3["Object store meta, out-of-line keys"]
    S1 --> I1["Index by-folder-updatedAt, compound"]
    S1 --> I2["Index by-tag, multiEntry"]
    S2 --> I3["Index by-note"]

Databases, versions and connections

A database is identified by its name (any string, including the empty string) within a storage key. It has a version, an unsigned 64-bit integer. A database that has never been opened has version 0, and the first open() without a version creates it at version 1. The only way to change the version, and therefore the only way to change the schema, is an upgrade transaction.

Details that bite in production:

  • indexedDB.open(name, 0) throws a TypeError synchronously. The version parameter uses Web IDL [EnforceRange], so NaN and Infinity also throw a TypeError, and fractional values are truncated: open("db", 2.9) opens version 2.
  • Opening with a lower version than the stored one fails with a VersionError. In a PWA this happens when an old tab, or a page served by an old service worker, runs code that predates your last schema bump. Handle it by prompting a reload (see Multiple tabs, workers and the service worker).
  • open() throws a SecurityError in contexts without a usable storage key, such as opaque origins (data: URLs, sandboxed iframes without allow-same-origin), and browsers can also refuse storage when the user has blocked site data.
  • A connection (IDBDatabase) is your handle to a database. Every tab, worker and service worker has its own connection, and there can be many connections to the same database at once. A connection's view of the schema (objectStoreNames, indexNames) is fixed for its lifetime, except during its own upgrade.

Object stores and records

An object store holds records: a key plus a value. Records are always kept sorted by key in ascending order. The value is a structured-serialized copy of what you passed in, not a reference. Mutating the object after put() has no effect on the stored record, and every get() returns a fresh object.

There are no partial updates. put() replaces the whole value, so a read-modify-write needs a readwrite transaction that reads the record, changes it and writes it back.

What you can store: structured serialization

Values go through the StructuredSerializeForStorage algorithm from the HTML standard, the same algorithm structuredClone() uses, with the extra rule that SharedArrayBuffer can't be stored.

Storable Not storable (throws DataCloneError)
Primitives except symbols, including BigInt, undefined and null Functions and class constructors
Plain objects and arrays, including cycles and sparse arrays Symbol values
Date, RegExp (lastIndex isn't preserved), Map, Set DOM nodes, Window, Promise, WeakMap, WeakSet
ArrayBuffer, typed arrays, DataView Proxy objects, including Vue's reactive() state and MobX observables
Blob, File, FileList, ImageData, ImageBitmap SharedArrayBuffer (allowed for postMessage(), not for storage)
Error objects (name and message) Streams, MessagePort, Request, Response
CryptoKey, including non-extractable keys
FileSystemFileHandle and FileSystemDirectoryHandle

What serialization silently changes matters as much as what it rejects:

  • Class instances come back as plain objects. The prototype is lost, so methods and instanceof checks stop working. Private fields (#x) aren't copied at all. Rehydrate with a factory (Note.fromRecord(value)) after reading.
  • Getters are evaluated. Serialization reads each own enumerable property with [[Get]], so an own accessor's current value is stored as a data property. Getters defined in a class body live on the prototype and are dropped.
  • Getters can't touch the database. The spec makes the transaction inactive while it clones your value, so a getter that issues a request against the same transaction throws TransactionInactiveError.
  • Framework state must be unwrapped. put(reactiveState) throws DataCloneError because the value is a Proxy. Convert it first: toRaw() in Vue, toJS() in MobX, or a plain spread for a shallow object.

Store CryptoKey objects, not key material

Because CryptoKey is serializable, you can generate a non-extractable key with crypto.subtle.generateKey(..., false, [...]), store it in IndexedDB and use it later to encrypt local data. The raw key bytes are never exposed to JavaScript. This is the standard way to encrypt data at rest in a PWA without putting a secret in localStorage.

Keys, key paths and key generators

Valid keys and how they sort

Only four types (plus arrays of them) are valid keys. The spec's comparison algorithm orders them by type first, then by value:

flowchart LR
    N["number, from -Infinity to Infinity"] --> D["Date, by time value"]
    D --> S["string, by UTF-16 code units"]
    S --> B["binary: ArrayBuffer, typed array or DataView, bytewise"]
    B --> A["Array, element by element, then by length"]
  • Numbers sort numerically, and -Infinity is the lowest possible key. NaN isn't a valid key.
  • Dates sort by time value. An invalid Date isn't a valid key.
  • Strings sort by UTF-16 code unit, not by locale: "Z" < "a", and "é" sorts after "z". For case-insensitive or locale-aware ordering, store a normalized copy of the field (for example title.toLocaleLowerCase()) and index that.
  • Binary keys sort bytewise.
  • Arrays compare element by element using these same rules. When one array is a prefix of the other, the shorter one sorts first. Arrays can't contain themselves.

Everything else is not a key: booleans, null, undefined, plain objects, Maps, detached buffers. Passing one where a key is required throws a DataError. The most common surprise is booleans, covered under indexes.

indexedDB.cmp(a, b) exposes the comparison directly. It returns -1, 0 or 1, and throws a DataError for invalid keys, which makes it a cheap validator:

keys.js
indexedDB.cmp(10, "10"); // -1: every number sorts before every string
indexedDB.cmp(new Date(0), 0); // 1: dates sort after numbers
indexedDB.cmp(["a", 1], ["a"]); // 1: the longer array sorts after its prefix

export function isValidKey(value) {
  try {
    indexedDB.cmp(value, value);
    return true;
  } catch {
    return false; // DataError: not a valid key
  }
}

In-line and out-of-line keys

createObjectStore(name, { keyPath, autoIncrement }) decides where each record's key comes from. The four combinations behave differently:

keyPath autoIncrement Where the key comes from Constraints
null (default) false (default) The second argument: put(value, key) Omitting the key throws DataError. Values can be anything, including strings and Blobs
null true The key generator, or an explicit second argument Values can be anything
"id" false Read from value.id The value must be an object with a valid key at that path, or put() throws DataError. Passing a second argument throws DataError
"id" true value.id if present, otherwise generated and written into value.id The value must be an object so the key can be injected. Passing a second argument throws DataError

Stores with a key path use in-line keys; stores without one use out-of-line keys. Out-of-line stores are handy for key-value data such as a meta store holding put(cursor, "pullCursor").

Key path syntax

A key path is one of:

  • "", the empty string, meaning the value itself is the key. Only useful for stores of primitive values.
  • An identifier such as "id", or a dotted chain such as "author.id". No spaces and no bracket notation.
  • A non-empty array of such strings, ["folderId", "updatedAt"], which produces an array key (a compound key).

Key paths can only read properties that structured serialization copies, plus a few type-specific ones the spec lists: length on strings and arrays, size and type on Blob, and name and lastModified on File. An index on "attachment.size" works when attachment is a Blob.

createObjectStore() throws InvalidAccessError when autoIncrement is true and the key path is the empty string or an array, and SyntaxError for an invalid key path.

Key generators

A store created with autoIncrement: true has a key generator. The spec rules:

  • Each store has its own generator, starting at 1.
  • A generated key is the generator's current number, which then increments. Once the current number would exceed 2^53 (9007199254740992), generation fails and the write throws a ConstraintError.
  • Storing a record with an explicit numeric key greater than or equal to the current number bumps the generator to that key + 1. Non-numeric explicit keys (strings, dates, arrays) never affect it.
  • The generator never goes down. delete() and clear() don't reset it, so keys aren't reused.
  • The generator is part of the transaction. If a write fails or the transaction aborts, the current number rolls back.
key-generator-demo.js
// Inside an upgradeneeded handler, where the upgrade transaction is active.
const store = db.createObjectStore("log", { autoIncrement: true });
store.put("a"); // key 1
store.put("b", 10); // explicit numeric key: generator jumps to 11
store.put("c"); // key 11
store.put("d", "x"); // string key: generator unaffected
store.put("e"); // key 12
store.clear(); // removes records, but not the generator state
store.put("f"); // key 13

Auto-increment keys are local, not global

Generated keys are unique only within one store on one device. Two devices creating records offline will both produce key 1. For records that sync to a server, generate IDs on the client with crypto.randomUUID() (secure contexts only) and use a keyPath without autoIncrement. Reserve key generators for purely local sequences such as an outbox, where the numeric order doubles as FIFO order.

Indexes, including compound and multiEntry

An index is a sorted list of (index key, primary key) pairs over one object store, maintained automatically on every write. You create it during an upgrade:

store.createIndex(name, keyPath, { unique: false, multiEntry: false });

The spec details that matter:

  • Sort order. Index records sort by index key first, then by the primary key. Two notes updated at the same millisecond still have a stable, deterministic order.
  • Sparse by design. If evaluating the key path on a value fails (the property is missing) or yields an invalid key (a boolean, null, an object), the record is simply not indexed. No error is thrown. This is a feature: an index on dirtySince contains only records that have that property.
  • unique: true. A write that would create a duplicate index key fails with ConstraintError, which aborts the transaction unless you cancel the error event. If you add a unique index during an upgrade and existing data already violates it, createIndex() still returns an index, but the browser then aborts the whole upgrade transaction, and open() fails with an AbortError.
  • multiEntry: true. When the key path yields an array, the index gets one entry per element (duplicates within one record's array are collapsed, and invalid elements are skipped) instead of one entry for the whole array. That is how you index tags.
  • Compound + multiEntry is not allowed. createIndex() throws InvalidAccessError if the key path is an array and multiEntry is true.
  • Index names are unique per store. Creating a duplicate throws ConstraintError. Rename with index.name = "new-name" during an upgrade (Chrome 55, Firefox 49, Safari 10.1).

Designing indexes for the queries you run

Query Index Range
Notes updated since a timestamp by-updatedAt on "updatedAt" IDBKeyRange.lowerBound(ts, true)
Notes in a folder, newest first by-folder-updatedAt on ["folderId", "updatedAt", "id"] IDBKeyRange.bound([folder], [folder, []]), direction "prev"
Notes with a tag by-tag on "tags", multiEntry: true IDBKeyRange.only("urgent")
Unsynced notes by-dirty on "dirtySince" (sparse) No range: every record in the index
User by email by-email on "emailLower", unique: true IDBKeyRange.only(email.toLowerCase())

The compound prefix trick

To select every entry whose compound key starts with a given prefix, use the key ordering rules from above: [folder] is shorter than any [folder, x], so it sorts before all of them, and an array sorts after every non-array, so [folder, []] sorts after every [folder, <number, date, string or binary>]:

compound-range.js
// Every note in folderId, whatever the type of the second and third components.
const inFolder = IDBKeyRange.bound([folderId], [folderId, []]);

// Notes in folderId updated in the last 24 hours.
const recent = IDBKeyRange.bound(
  [folderId, Date.now() - 86_400_000],
  [folderId, []],
);

This avoids fragile sentinels such as "￿", which isn't actually the largest string.

Booleans can't be indexed

{ archived: false } never appears in an index on "archived", because booleans aren't valid keys. Either store 0 / 1, or use the sparse behavior deliberately: set archivedAt: Date.now() on archived records and delete the property on restore. An index on archivedAt then contains exactly the archived records.

Working around "no compound multiEntry"

To query "notes tagged urgent in folder work" with one index, store a derived array of arrays and index it with multiEntry. The key path is a single string, so multiEntry is allowed, and each element is itself a valid array key:

derived-multientry.js
// Written by your save function, never edited directly.
note.folderTags = note.tags.map((tag) => [note.folderId, tag]);

// In the upgrade:
notes.createIndex("by-folder-tag", "folderTags", { multiEntry: true });

// Query:
const urgentWork = await index("by-folder-tag").getAll(IDBKeyRange.only(["work", "urgent"]));

Every index costs write time and disk space on every put(), so index only fields you actually query by.

Transactions

Every read and write happens inside a transaction. A transaction has a scope (the object stores it can touch, fixed at creation), a mode and a durability hint:

IDBTransaction transaction((DOMString or sequence<DOMString>) storeNames,
                           optional IDBTransactionMode mode = "readonly",
                           optional IDBTransactionOptions options = {});

enum IDBTransactionMode { "readonly", "readwrite", "versionchange" };
enum IDBTransactionDurability { "default", "strict", "relaxed" };
dictionary IDBTransactionOptions { IDBTransactionDurability durability = "default"; };
Mode Can do Created by
"readonly" (default) get, getAll, count, cursors db.transaction(stores)
"readwrite" Everything above plus put, add, delete, clear, cursor.update(), cursor.delete() db.transaction(stores, "readwrite")
"versionchange" Everything above plus creating, renaming and deleting stores and indexes Only the browser, during open() with a higher version

db.transaction() throws synchronously: NotFoundError for an unknown store name, InvalidAccessError for an empty list of stores, InvalidStateError if the connection is closing or closed, and TypeError for an invalid mode. A mutating call in a read-only transaction throws ReadOnlyError.

Scope and scheduling

The spec's scheduling rules:

  • A read-only transaction can start when no read/write transaction that was created earlier, with an overlapping scope, is still unfinished.
  • A read/write transaction can start when no transaction of any mode that was created earlier, with an overlapping scope, is still unfinished.
  • Browsers may add constraints, such as not running non-overlapping read/write transactions in parallel.

The consequences:

  • Any number of read-only transactions run concurrently, even on the same stores. While a read-only transaction is live, it sees a consistent snapshot: two reads of the same key return the same result.
  • Read/write transactions on the same store are serialized in creation order. A slow read/write transaction delays every later transaction on that store, including reads. That's why Chrome observed faster reads after switching to relaxed durability: reads queue behind write commits.
  • Scope is the unit of contention. db.transaction(["notes", "outbox"], "readwrite") blocks readers of outbox too. Open transactions on exactly the stores you need.

Transaction lifetime and auto-commit

There is no begin/end pair in IndexedDB. A transaction stays usable only while it is active, and it commits automatically once it has no work left:

stateDiagram-v2
    [*] --> Active: created by db.transaction
    Active --> Inactive: creating task and its microtasks finish
    Inactive --> Active: a request success or error event is dispatched
    Active --> Inactive: event dispatch ends
    Inactive --> Committing: no pending requests left
    Active --> Committing: commit called
    Committing --> Finished: complete event
    Active --> Finished: abort called or unhandled request error
    Inactive --> Finished: abort
    Committing --> Finished: commit failed, abort event
    Finished --> [*]

Precisely, per the IndexedDB and HTML standards:

  1. db.transaction() returns an active transaction. At the end of the current task, after the microtask checkpoint, HTML runs "cleanup Indexed Database transactions", which makes it inactive.
  2. Before a request's success or error event is dispatched, the transaction becomes active again. After dispatch it becomes inactive. If an event listener threw an exception, the transaction aborts with an AbortError, even if the request succeeded.
  3. After dispatch, if the transaction has no pending requests, it starts committing.
  4. You can place requests only while it is active. Otherwise the call throws TransactionInactiveError synchronously.

Microtasks are the reason promise wrappers work. A microtask checkpoint runs after each event listener returns, while the transaction is still active for that dispatch. When a wrapper resolves a promise in the success listener, the code after your await runs during that checkpoint and can place the next request in time. Anything that resolves in a later task misses the window: fetch(), setTimeout(), crypto.subtle.*, postMessage() round trips, navigator.locks, and awaiting another database.

refresh-note.js
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const note = await promisify(store.get(id)); // fine: resolved inside the success event
const fresh = await fetch(`/api/notes/${id}`).then((r) => r.json()); // later task
store.put({ ...note, ...fresh }); // throws TransactionInactiveError: tx already committed
refresh-note.js
// Do the network I/O before the transaction exists...
const fresh = await fetch(`/api/notes/${id}`).then((r) => r.json());

// ...then read-modify-write in one uninterrupted transaction.
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const note = await promisify(store.get(id));
store.put({ ...note, ...fresh });
await transactionDone(tx);
refresh-note.js
const fresh = await fetch(`/api/notes/${id}`).then((r) => r.json());

const tx = db.transaction("notes", "readwrite");
const note = await tx.store.get(id);
await Promise.all([tx.store.put({ ...note, ...fresh }), tx.done]);

The raw examples on this page use two small helpers. They are the entire "promise layer" most apps need:

idb-helpers.js
/** Resolves with request.result, rejects with request.error. */
export function promisify(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

/** Resolves when the transaction commits, rejects when it aborts. */
export function transactionDone(tx) {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    // Request errors bubble to the transaction and abort it unless cancelled,
    // so listening for "abort" catches both explicit and error-driven aborts.
    tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
  });
}

When the async step must sit between the read and the write

Sometimes you need to read, compute something asynchronously (hash a Blob, encrypt with crypto.subtle, ask a worker), and write back. You can't hold a transaction across that. Use optimistic concurrency: read in one transaction, compute, then re-read in a second transaction and write only if the record is unchanged. A rev counter makes the check cheap:

optimistic-update.js
/**
 * Applies an async computation to a record without holding a transaction
 * across the async step. Retries if another context changed the record meanwhile.
 */
export async function updateWithAsyncStep(db, storeName, key, compute, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const before = await db.get(storeName, key); // idb shortcut: its own read-only transaction
    const patch = await compute(before); // fetch, crypto.subtle, a worker... anything

    const tx = db.transaction(storeName, "readwrite");
    const current = await tx.store.get(key);
    if ((current?.rev ?? 0) !== (before?.rev ?? 0)) {
      await tx.done; // nothing was written; let the empty transaction commit
      continue; // someone else won the race: recompute from the new state
    }
    const next = { ...current, ...patch, rev: (current?.rev ?? 0) + 1 };
    await Promise.all([tx.store.put(next), tx.done]);
    return next;
  }
  throw new Error(`Record ${String(key)} kept changing; gave up after ${maxAttempts} attempts`);
}

commit() and abort()

tx.commit() (Chrome 76, Firefox 74, Safari 15) starts committing immediately instead of waiting for the last request's success event to be dispatched. The browser still waits for pending requests to finish, and still fires their events, but you can't place new requests afterwards. It saves a little latency at the end of large batches. Call it as tx.commit?.() if you support older engines.

tx.abort() rolls back every change the transaction made, including key generator state, and fires abort at the transaction. After an explicit abort(), tx.error is null. When an error caused the abort, tx.error holds that DOMException. Calling abort() on a finished transaction throws InvalidStateError, so wrap it in try when cleaning up after a failure.

Synchronous exceptions don't abort a transaction

If store.put(value) throws synchronously (a DataError for a bad key or a DataCloneError for an unserializable value), the requests you already queued in that transaction are still committed when it auto-commits. Only failed requests, meaning asynchronous error events, abort the transaction automatically. When you write several records that must be all-or-nothing, catch synchronous errors and call tx.abort() yourself, as the inTransaction() helper below does.

Error events, preventDefault() and bubbling

A failed request fires an error event at the request. The event bubbles to the transaction and then to the connection, and it's cancelable:

  • If no listener calls event.preventDefault(), the transaction aborts with that error.
  • If a listener calls preventDefault(), the transaction continues. This is how you do "insert if absent" with add(): treat ConstraintError as expected and keep going.
  • If any listener throws, the transaction aborts with an AbortError, even if preventDefault() was called.
insert-if-absent.js
/** Adds each record unless its key already exists. Resolves with the number inserted. */
export function insertMissing(db, storeName, records) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, "readwrite");
    const store = tx.objectStore(storeName);
    let inserted = 0;
    for (const record of records) {
      const request = store.add(record);
      request.onsuccess = () => inserted++;
      request.onerror = (event) => {
        if (request.error.name === "ConstraintError") {
          event.preventDefault(); // expected duplicate: don't abort the transaction
          event.stopPropagation(); // and don't report it to tx.onerror / db.onerror
        }
      };
    }
    tx.oncomplete = () => resolve(inserted);
    tx.onabort = () => reject(tx.error);
  });
}

Always treat the transaction's complete event as the success signal, never an individual request's success. A request can succeed and the transaction can still abort later because of another request, a quota error at commit time, or an exception in a listener. Quota errors in particular often surface only at commit, as an abort with tx.error.name === "QuotaExceededError". See Handling QuotaExceededError.

Durability: strict vs relaxed

The durability hint trades crash safety for speed. The spec defines:

  • "strict": the browser may report the commit only after verifying all changes reached persistent storage. In practice this means flushing OS buffers before complete fires.
  • "relaxed": the browser may report the commit as soon as the changes are handed to the operating system, without waiting for a flush.
  • "default": the browser's default behavior for the storage bucket.

Chrome changed its default from strict to relaxed in Chrome 121, stating that this aligns it with Firefox and Safari, which already behaved that way. The Chrome team reported that switching to relaxed made real-world workloads between 3 and 30 times faster, and improved battery life and read speed, because reads queue behind write flushes (announcement). The durability option is accepted by Chrome 83+, Safari 15+ and Firefox, and the read-only tx.durability attribute is exposed in Chrome 83, Safari 15 and Firefox 126.

With relaxed durability, a power loss or OS crash within a few seconds of complete can lose the transaction. Durability never affects atomicity: you lose the whole transaction or none of it. Use this rule:

  • "relaxed" for anything you can re-fetch or recompute: server data pulls, cache metadata, search indexes, UI state.
  • "strict" when the transaction holds the only copy of something the user did, or when a later step deletes the previous copy: offline edits and outbox entries before they reach the server, and migrations that copy data into a new store before deleting the old one.
durability.js
// The user's edit exists nowhere else until the outbox is flushed: flush to disk.
const tx = db.transaction(["notes", "outbox"], "readwrite", { durability: "strict" });

// A pull from the server can simply be repeated after a crash.
const pull = db.transaction(["notes", "meta"], "readwrite", { durability: "relaxed" });

Opening databases, versioning and migrations

The open sequence

indexedDB.open(name, version) returns an IDBOpenDBRequest that fires, in order:

  1. blocked (only if an upgrade or delete is needed and other connections stay open after receiving versionchange).
  2. upgradeneeded, if version is greater than the stored version. event.oldVersion is the stored version (0 for a new database), event.newVersion the requested one, and request.transaction the versionchange transaction.
  3. success, after the upgrade transaction completes, with request.result as the connection. Or error, with VersionError (lower version) or AbortError (upgrade aborted, in which case the database keeps its old version and schema).
sequenceDiagram
    participant A as Tab A on schema v2
    participant IDB as IndexedDB
    participant B as Tab B on schema v3
    B->>IDB: indexedDB.open notes-app version 3
    IDB->>A: versionchange event, oldVersion 2, newVersion 3
    alt Tab A closes its connection
        A->>IDB: db.close
        IDB->>B: upgradeneeded, oldVersion 2, newVersion 3
        B->>IDB: create stores and indexes, migrate data
        IDB->>B: success, connection on version 3
    else Tab A ignores versionchange
        IDB->>B: blocked event
        Note over B: open request stays pending until Tab A closes
    end

During upgradeneeded, db.transaction() throws InvalidStateError: the only transaction you can use is request.transaction. The schema methods (createObjectStore, deleteObjectStore, createIndex, deleteIndex and the name setters) throw InvalidStateError anywhere else.

Writing migrations

Write migrations as a chain of if (oldVersion < N) blocks, never as independent branches. A user can jump from any old version to the current one, so every step from their version onward must run, in order. Rules:

  • Never edit a migration that has shipped. Add a new version instead.
  • You can't change a store's key path or autoIncrement flag. Create a new store, copy the records with a cursor, delete the old store, and optionally rename the new one.
  • deleteObjectStore() deletes the data and its indexes immediately (and permanently once the upgrade commits).
  • Data migrations run inside the upgrade transaction. Use cursors on request.transaction.objectStore(...). You can't await non-IndexedDB work here any more than in any other transaction.
  • An aborted upgrade leaves everything as it was. Throwing in the handler, a failed request, or an explicit transaction.abort() rolls back the whole upgrade and open() fails with AbortError. That's the safety net for data migrations: fail loudly rather than half-migrate.
open-db.js
const DB_NAME = "notes-app";
const DB_VERSION = 3;

export function openNotesDB({ onBlocked, onVersionChange } = {}) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = request.result;
      const tx = request.transaction; // the versionchange transaction
      const { oldVersion } = event;

      if (oldVersion < 1) {
        const notes = db.createObjectStore("notes", { keyPath: "id" });
        notes.createIndex("by-updatedAt", "updatedAt");
      }
      if (oldVersion < 2) {
        const outbox = db.createObjectStore("outbox", { keyPath: "seq", autoIncrement: true });
        outbox.createIndex("by-note", "noteId");
        db.createObjectStore("outbox-dead", { keyPath: "seq" });
        db.createObjectStore("meta"); // out-of-line keys: put(value, "pullCursor")
      }
      if (oldVersion < 3) {
        const notes = tx.objectStore("notes");
        notes.createIndex("by-folder-updatedAt", ["folderId", "updatedAt", "id"]);
        notes.createIndex("by-tag", "tags", { multiEntry: true });
        notes.createIndex("by-dirty", "dirtySince"); // sparse: only unsynced notes

        // Data migration: v2 stored tags as "a, b"; v3 stores ["a", "b"].
        notes.openCursor().onsuccess = (e) => {
          const cursor = e.target.result;
          if (!cursor) return; // done; the upgrade commits when nothing is pending
          const note = cursor.value;
          if (typeof note.tags === "string") {
            note.tags = note.tags.split(",").map((t) => t.trim()).filter(Boolean);
            cursor.update(note);
          }
          cursor.continue();
        };
      }
    };

    request.onblocked = (event) => {
      // Other connections on event.oldVersion ignored versionchange.
      // The request stays pending and succeeds once they close.
      onBlocked?.(event.oldVersion, event.newVersion);
    };

    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = (event) => {
        db.close(); // step aside so the other context can upgrade or delete
        onVersionChange?.(event.newVersion); // null means the database is being deleted
      };
      resolve(db);
    };

    request.onerror = () => reject(request.error); // VersionError, AbortError, UnknownError...
  });
}
open-db.js
import { openDB } from "idb";

const DB_NAME = "notes-app";
const DB_VERSION = 3;

export function openNotesDB({ onBlocked, onVersionChange } = {}) {
  return openDB(DB_NAME, DB_VERSION, {
    async upgrade(db, oldVersion, newVersion, tx) {
      try {
        if (oldVersion < 1) {
          const notes = db.createObjectStore("notes", { keyPath: "id" });
          notes.createIndex("by-updatedAt", "updatedAt");
        }
        if (oldVersion < 2) {
          const outbox = db.createObjectStore("outbox", { keyPath: "seq", autoIncrement: true });
          outbox.createIndex("by-note", "noteId");
          db.createObjectStore("outbox-dead", { keyPath: "seq" });
          db.createObjectStore("meta");
        }
        if (oldVersion < 3) {
          const notes = tx.objectStore("notes");
          notes.createIndex("by-folder-updatedAt", ["folderId", "updatedAt", "id"]);
          notes.createIndex("by-tag", "tags", { multiEntry: true });
          notes.createIndex("by-dirty", "dirtySince");
          for await (const cursor of notes) {
            const note = cursor.value;
            if (typeof note.tags === "string") {
              const tags = note.tags.split(",").map((t) => t.trim()).filter(Boolean);
              await cursor.update({ ...note, tags });
            }
          }
        }
      } catch (error) {
        // idb ignores the promise an async upgrade() returns, so an error thrown
        // after the first await wouldn't abort the upgrade. Abort explicitly.
        try {
          tx.abort();
        } catch {
          // already aborted by the failing request
        }
        throw error;
      }
    },
    blocked(currentVersion, blockedVersion) {
      onBlocked?.(currentVersion, blockedVersion);
    },
    blocking(currentVersion, blockedVersion, event) {
      event.target.close(); // event.target is the underlying IDBDatabase
      onVersionChange?.(blockedVersion);
    },
  });
}

Version numbers and service worker updates

In a PWA, the page and the service worker update on different schedules (see Updating Service Workers). Treat the schema version as part of your deploy:

  • New code opens with a higher version. Every older context gets versionchange. A tab should close its connection immediately, then either reload (if it isn't focused and has nothing unsaved) or show a "new version available" prompt. The service worker should also close its connection and reopen lazily on its next event.
  • Old code opens with a lower version and gets VersionError. That happens when an old tab or an old service worker keeps running after an upgrade. Catch VersionError and ask for a reload. Never "fix" it by opening without a version, because that code would then run against a schema it doesn't understand.
  • Deploy schema changes that are backward compatible with the previous release when you can (adding stores and indexes, not removing fields old code reads), so a brief overlap between versions doesn't break anything.

Enumerating and deleting databases

indexedDB.databases() (Chrome 72, Safari 14, Firefox 126) resolves with [{ name, version }] for the storage key. The result is a snapshot, and it excludes databases still being created. Use it to clean up databases from old releases:

cleanup-old-dbs.js
const KEEP = new Set(["notes-app", "cache-meta"]);

export async function deleteObsoleteDatabases() {
  if (!indexedDB.databases) return; // very old engines: nothing to enumerate
  for (const { name } of await indexedDB.databases()) {
    if (KEEP.has(name)) continue;
    await new Promise((resolve, reject) => {
      const request = indexedDB.deleteDatabase(name);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
      // Fires if connections stay open; the delete completes when they close.
      request.onblocked = () => console.warn(`Deleting ${name} is waiting for open connections`);
    });
  }
}

Deleting a database fires versionchange with newVersion === null at every open connection. Deleting a database that doesn't exist succeeds.

Multiple tabs, workers and the service worker

Every tab, iframe, worker and service worker of your origin can hold its own connection to the same database. The browser serializes their transactions according to the scheduling rules, so concurrent access is safe. What needs care is schema upgrades, dead connections and change notification.

Handling versionchange and blocked

The spec's own guidance for versionchange is to do whatever ultimately closes the connection: save unsaved data, then close and reload, or close and ask the user to reload. The worst response is to ignore it. The new version's open() then stays blocked indefinitely, and to the user the newly deployed version of your app appears to hang on startup.

  • In pages, call db.close() synchronously in the versionchange listener, then decide: reload if document.visibilityState === "hidden" and nothing is unsaved, or show a non-modal "Reload to update" banner.
  • In the service worker, close and forget the connection. The next event that needs the database reopens it with the new version, which fails with VersionError if the running service worker is older than the schema. In that case the new service worker is about to take over anyway.
  • On blocked, tell the user that another tab of the app is preventing the update. The request completes by itself as soon as the other connections close.

Abnormal closes and dead connections

The spec lets the browser close a connection without being asked, for example when the user clears site data, on I/O errors or corruption, or when the backing store is lost. In that case the connection receives a close event (Chrome 30, Firefox 50, Safari 10.1). An explicit db.close() never fires it. After an abnormal close, db.transaction() throws InvalidStateError.

Mobile engines add another failure mode: when an installed PWA is suspended in the background and later resumed, its database connection can be dead without a close event, and the next transaction fails with InvalidStateError or UnknownError. WebKit users have widely reported UnknownError: Connection to Indexed Database server lost. Refresh the page to try again. Don't cache a connection forever. Reset it on close, versionchange and these errors, and retry once:

connection.js
import { openNotesDB } from "./open-db.js";

let dbPromise = null;

/** Returns a live connection, reopening after abnormal closes. */
export function getDB() {
  dbPromise ??= openNotesDB({
    onVersionChange: () => {
      dbPromise = null; // closed by the listener in open-db.js; reopen lazily
      announceUpdateAvailable();
    },
    onBlocked: () => showBanner("Close other tabs of this app to finish updating."),
  }).then(
    (db) => {
      db.addEventListener("close", () => {
        dbPromise = null; // storage cleared, I/O error, or backing store lost
      });
      return db;
    },
    (error) => {
      dbPromise = null; // let the next call retry instead of caching the failure
      throw error;
    },
  );
  return dbPromise;
}

/** Closes the connection deliberately, for example on pagehide. getDB() reopens on demand. */
export async function closeDB() {
  const pending = dbPromise;
  dbPromise = null; // reset first so no caller gets the connection being closed
  const db = await pending?.catch(() => null);
  db?.close(); // waits for running transactions to finish, then closes
}

/** Runs fn(db), retrying once with a fresh connection if the old one died. */
export async function withDB(fn) {
  try {
    return await fn(await getDB());
  } catch (error) {
    if (error?.name !== "InvalidStateError" && error?.name !== "UnknownError") throw error;
    dbPromise = null;
    return fn(await getDB());
  }
}

function announceUpdateAvailable() {
  if (typeof document !== "undefined" && document.visibilityState === "hidden") {
    location.reload(); // nobody is looking: reload into the new version
  } else {
    showBanner("A new version is available. Reload to continue.");
  }
}

function showBanner(message) {
  // Replace with your UI. In a service worker, post to clients instead.
  globalThis.dispatchEvent?.(new CustomEvent("app-banner", { detail: message }));
}

Only retry operations that are safe to repeat. A retried write that already committed before the error was reported would be applied twice unless it's idempotent (a put() of a full record is, an add() to an auto-increment store isn't).

Back/forward cache

Pages with an open IndexedDB connection may be excluded from the back/forward cache in some browsers, because freezing a page mid-transaction could block other tabs (web.dev on bfcache). With a lazily reopening getDB(), the fix is two listeners:

bfcache.js
import { closeDB } from "./connection.js";

// Close without opening: closeDB() is a no-op when no connection exists.
addEventListener("pagehide", () => {
  closeDB();
});
// Nothing to do on pageshow: the next getDB() call reopens.
// An explicit close() never fires the "close" event, so no handler runs twice.

closeDB() resets the cached promise before closing, so a request that arrives during pagehide or right after pageshow gets a fresh connection instead of the one being closed.

The service worker as a database client

The service worker is just another connection, with a few specifics:

  • It can be terminated between events. A transaction still running when the browser stops the worker is aborted, never half-committed. Wrap database work in event.waitUntil() so the browser keeps the worker alive until it finishes.
  • A module-level connection is fine. It's recreated when the worker restarts, and getDB() above handles closes in between.
  • It must handle versionchange. A service worker that keeps an old-version connection open blocks every page's upgrade until it's terminated.
  • It shares data with pages, not memory. Data written by the page is visible to the service worker's next transaction, and vice versa. Use messaging for the notification, not the data. See Messaging & the Clients API.

Change notifications across contexts

IndexedDB has no change events. An "IndexedDB Observers" API was prototyped in Chromium and abandoned (its Chrome Platform Status entry reads "No longer pursuing"). Broadcast your own notifications after the transaction completes:

changes.js
const channel = new BroadcastChannel("notes-app:changes");
const local = new EventTarget();

/** Call after tx.done resolves, never before: other contexts would read stale data. */
export function announce(change) {
  channel.postMessage(change); // other tabs, workers and the service worker
  local.dispatchEvent(new CustomEvent("change", { detail: change })); // BroadcastChannel skips the sender
}

export function onChange(callback) {
  const fromChannel = (event) => callback(event.data);
  const fromLocal = (event) => callback(event.detail);
  channel.addEventListener("message", fromChannel);
  local.addEventListener("change", fromLocal);
  return () => {
    channel.removeEventListener("message", fromChannel);
    local.removeEventListener("change", fromLocal);
  };
}

BroadcastChannel is available in windows and all worker types in Chrome 54, Firefox 38 and Safari 15.4.

Reading data: key ranges, getAll and cursors

Key ranges

Every read method accepts either a single key or an IDBKeyRange:

Range Matches Example
IDBKeyRange.only(k) key === k only("urgent")
IDBKeyRange.lowerBound(k, open = false) key >= k, or > k when open lowerBound(lastSeen, true)
IDBKeyRange.upperBound(k, open = false) key <= k, or < k when open upperBound(Date.now())
IDBKeyRange.bound(lo, hi, loOpen = false, hiOpen = false) Between lo and hi bound([folder], [folder, []])

bound() throws DataError if lo > hi, or if lo equals hi and either end is open. range.includes(key) (Chrome 52, Firefox 47, Safari 10.1) tests membership without touching the database.

get, getKey, getAll, getAllKeys and count

Method (on a store or index) Result
get(query) Value of the first record in key order matching the key or range, or undefined
getKey(query) Primary key of the first match, without deserializing the value
getAll(query?, count?) Array of values, in key order, at most count (0 or omitted means no limit)
getAllKeys(query?, count?) Array of primary keys
count(query?) Number of matching records

On an index, get() returns the first record for that index key (with the lowest primary key on ties), and getAllKeys() returns primary keys, not index keys.

get() resolving with undefined is ambiguous in an out-of-line store where undefined is a legal value. Use getKey() or count() to test existence.

getAllRecords() and the options dictionary

Before 2025, getAll() couldn't read in reverse, and there was no way to fetch keys and values together except a cursor. IndexedDB 3.0 adds both:

dictionary IDBGetAllOptions {
  any query = null;
  [EnforceRange] unsigned long count;
  IDBCursorDirection direction = "next";
};

IDBRequest getAllRecords(optional IDBGetAllOptions options = {}); // resolves with IDBRecord[]
IDBRequest getAll(optional any queryOrOptions, optional unsigned long count);
IDBRequest getAllKeys(optional any queryOrOptions, optional unsigned long count);

interface IDBRecord {
  readonly attribute any key;        // index key (for an index) or primary key (for a store)
  readonly attribute any primaryKey;
  readonly attribute any value;
};

getAllRecords() shipped in Chrome and Edge 141 and Firefox 153, and is in Safari Technology Preview. Chrome 141 also accepts the options dictionary (with direction: "prev") in getAll() and getAllKeys(). MDN's compatibility data doesn't list that form for Firefox or Safari yet. Chromium's feature entry reports that one Microsoft workload gained 350 ms by replacing cursor iteration with these calls.

Feature-detect both separately. In an engine without the options form, getAll({ query, count }) throws DataError, because a plain object isn't a valid key:

read-page.js
import { promisify } from "./idb-helpers.js";

const hasGetAllRecords =
  typeof IDBObjectStore !== "undefined" && "getAllRecords" in IDBObjectStore.prototype;

/**
 * Reads up to `limit` records after `afterKey` from a store in an active transaction.
 * Returns [{ key, value }] in key order. Keyset pagination: O(limit), stable under inserts.
 */
export function readPage(store, { afterKey, limit = 100 } = {}) {
  const query = afterKey === undefined ? null : IDBKeyRange.lowerBound(afterKey, true);

  if (hasGetAllRecords) {
    return promisify(store.getAllRecords({ query, count: limit })).then((records) =>
      records.map(({ primaryKey, value }) => ({ key: primaryKey, value })),
    );
  }

  // Fallback: two requests in the same transaction. Both see the same data, because
  // no other transaction can modify this store while ours is live.
  return Promise.all([
    promisify(store.getAllKeys(query, limit)),
    promisify(store.getAll(query, limit)),
  ]).then(([keys, values]) => keys.map((key, i) => ({ key, value: values[i] })));
}

Cursors

A cursor walks a store or index in order, one record per success event, and lets you skip, update or delete as you go. openCursor(query?, direction = "next") yields IDBCursorWithValue. openKeyCursor() yields IDBCursor without values, which is much cheaper when you only need keys.

Direction Order Duplicates (indexes only)
"next" Ascending All records
"nextunique" Ascending Only the first record (lowest primary key) per index key
"prev" Descending All records
"prevunique" Descending Only the first record (lowest primary key) per index key

Cursor methods:

  • continue(key?) moves to the next record, or jumps to the first record >= key (<= key for prev). Jumping backwards throws DataError.
  • continuePrimaryKey(key, primaryKey) (Chrome 58, Firefox 10, Safari 10.1) jumps to an exact (index key, primary key) position. Only valid on index cursors with direction "next" or "prev". This is what makes index pagination exact.
  • advance(n) skips n records. It still walks them internally, so advance(10000) isn't a cheap OFFSET.
  • update(value) and delete() modify the current record (readwrite transactions only). update() on an in-line store throws DataError if the new value's key doesn't match.
  • Calling continue() or advance() twice before the next success event throws InvalidStateError.
  • cursor.request (Chrome 76, Firefox 77, Safari 15) returns the request that yields this cursor. It's what lets promise wrappers resolve each step.

The same IDBRequest fires success repeatedly, once per record, with request.result === null at the end:

archive-old.js
/** Moves notes untouched for 90 days into the archive store. Resolves with the count. */
export function archiveOldNotes(db, now = Date.now()) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(["notes", "archive"], "readwrite");
    const archive = tx.objectStore("archive");
    const range = IDBKeyRange.upperBound(now - 90 * 86_400_000);
    let moved = 0;

    const request = tx.objectStore("notes").index("by-updatedAt").openCursor(range);
    request.onsuccess = () => {
      const cursor = request.result;
      if (!cursor) return; // end of range; tx commits when requests drain
      archive.put(cursor.value);
      cursor.delete();
      moved++;
      cursor.continue();
    };
    tx.oncomplete = () => resolve(moved);
    tx.onabort = () => reject(tx.error);
  });
}
archive-old.js
export async function archiveOldNotes(db, now = Date.now()) {
  const tx = db.transaction(["notes", "archive"], "readwrite");
  const archive = tx.objectStore("archive");
  const range = IDBKeyRange.upperBound(now - 90 * 86_400_000);
  let moved = 0;

  // idb 8 includes async iterators; continue() is called for you after each step.
  for await (const cursor of tx.objectStore("notes").index("by-updatedAt").iterate(range)) {
    await archive.put(cursor.value);
    await cursor.delete();
    moved++;
  }
  await tx.done;
  return moved;
}

Pagination without OFFSET

For "load more" lists, keep the last key you returned and start the next page strictly after it (keyset pagination). On a store, readPage() above does exactly that. On an index, the position must include the primary key, because many records can share an index key. Either use continuePrimaryKey(), or, simpler, append the primary key to the index key path, as by-folder-updatedAt does with ["folderId", "updatedAt", "id"], so every index key is unique:

list-folder.js
/** Newest-first page of notes in a folder. Pass the returned `next` back to get the following page. */
export async function listFolder(db, folderId, { next = null, limit = 50 } = {}) {
  const upper = next ? [folderId, next.updatedAt, next.id] : [folderId, []];
  const range = IDBKeyRange.bound([folderId], upper, false, next !== null);
  const index = db.transaction("notes").store.index("by-folder-updatedAt");

  const notes = [];
  for await (const cursor of index.iterate(range, "prev")) {
    notes.push(cursor.value);
    if (notes.length === limit) break; // leaving the loop stops iteration
  }
  const last = notes.at(-1);
  return { notes, next: notes.length === limit ? { updatedAt: last.updatedAt, id: last.id } : null };
}

In Chrome 141+, the same page is a single request: index.getAll({ query: range, count: limit, direction: "prev" }).

Storing Blobs and files

IndexedDB stores Blob and File objects natively, and you should use them for binary data:

  • Never base64-encode binary data for storage. It inflates size by a third, costs CPU both ways, and turns a cheap reference into a huge string that's copied on every read.
  • Prefer Blob over ArrayBuffer. An ArrayBuffer is serialized inline with the record and fully copied into memory on every get(), getAll() or cursor step. A stored Blob deserializes as a lightweight handle, and its bytes are read only when you call blob.arrayBuffer(), blob.stream() or create an object URL.
  • Keep large Blobs out of stores you list. Put metadata (title, size, type, ETag) in one store and the Blob in another store keyed by the same ID. Listing then never touches the binary data, and the metadata store stays small and fast.

Chromium stores values above a size threshold as separate files next to its database. Users' "free up space" tools can delete those files, which makes the record permanently unreadable. Since Chromium 132, reading such a record fails with NotReadableError ("Data lost due to missing file"), while transient failures such as low memory fail with UnknownError (Chromium 130 and 131 used NotFoundError and DataError). Treat NotReadableError as "delete the record and re-download":

media-cache.js
/** Returns an object URL for a cached image, re-downloading when the stored bytes are lost. */
export async function getImageURL(db, id, sourceUrl) {
  let record;
  try {
    record = await db.get("image-blobs", id);
  } catch (error) {
    if (error.name !== "NotReadableError") throw error; // UnknownError etc.: transient, let caller retry
    await db.delete("image-blobs", id); // the file behind this record is gone for good
  }
  if (!record) {
    const response = await fetch(sourceUrl);
    if (!response.ok) throw new Error(`HTTP ${response.status} for ${sourceUrl}`);
    record = { id, blob: await response.blob(), etag: response.headers.get("ETag"), storedAt: Date.now() };
    // Network I/O finished before the transaction starts, so it can't go inactive.
    await db.put("image-blobs", record);
  }
  return URL.createObjectURL(record.blob); // revoke with URL.revokeObjectURL() when done
}

response.blob() buffers the whole body in memory before you can store it. For media larger than a few tens of megabytes, stream it into Cache Storage (cache.put() streams) or OPFS instead, and keep only metadata in IndexedDB.

Performance

Batch writes into one transaction

Each transaction has fixed costs: scheduling behind other transactions, cross-process messaging in multi-process browsers, and a commit that may flush to disk. Writing 1,000 records in 1,000 transactions pays that cost 1,000 times. Queue every request in one transaction and wait only for complete:

bulk-put.js
/** Writes all records atomically. Resolves with the count once committed. */
export function bulkPut(db, storeName, records, { durability = "relaxed" } = {}) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, "readwrite", { durability });
    const store = tx.objectStore(storeName);
    tx.oncomplete = () => resolve(records.length);
    tx.onabort = () => reject(tx.error);
    try {
      for (const record of records) store.put(record); // queue synchronously, don't await each
      tx.commit?.(); // start committing without waiting for the last success event
    } catch (error) {
      // A synchronous DataError/DataCloneError doesn't abort the transaction by itself:
      // abort so the records already queued aren't committed.
      tx.abort();
      reject(error);
    }
  });
}
bulk-put.js
export async function bulkPut(db, storeName, records, { durability = "relaxed" } = {}) {
  const tx = db.transaction(storeName, "readwrite", { durability });
  const done = tx.done;
  try {
    await Promise.all([...records.map((record) => tx.store.put(record)), done]);
  } catch (error) {
    done.catch(() => {}); // the abort below rejects it; the error is rethrown instead
    try {
      tx.abort();
    } catch {
      // already aborted by the failing request
    }
    throw error;
  }
  return records.length;
}

In the raw version, tx.abort() inside catch fires abort, which calls reject() a second time. That's harmless, because a settled promise ignores later calls.

Keep serialization off the critical path

put() serializes the value synchronously on the calling thread before it returns, and get() deserializes on the calling thread. Writing a large object graph from the main thread can take long enough to cause jank and hurt Interaction to Next Paint. For large imports:

  • Do them in a dedicated worker. IndexedDB works there, and the main thread stays responsive.
  • If they must run on the main thread, split them into several transactions and yield to the event loop between transactions (never inside one). This trades whole-import atomicity for responsiveness, so record progress (for example the last imported key in a meta store, in the same transaction as each chunk) to make the import resumable.

Read in bulk, and read less

  • getAll() with a count beats a cursor for bulk reads, because it's one request and one event instead of one per record. getAllRecords() removes the last reason to use a cursor for plain reads.
  • getAllKeys(), openKeyCursor() and count() don't deserialize values. Use them when you only need keys or numbers.
  • Reads queue behind overlapping read/write transactions. Keep write transactions short and narrow in scope.
  • Deserialization cost grows with value size. Split rarely used large fields into another store.

Keep indexes lean

Each index adds a write to every put() and delete() and takes disk space. multiEntry indexes on large arrays are especially costly, with one index entry per element. Index only fields you query by, and prefer one compound index that serves several queries through prefix ranges over several single-field indexes.

Reuse the connection

Opening a database is expensive: it may involve disk access, schema loading and, for new versions, an upgrade. Open once per context and reuse the connection, as getDB() does. Don't open and close per operation.

Chromium's backend migration

Chromium historically stored IndexedDB in LevelDB plus flat files for large values. Its Chrome Platform Status entries describe a rewrite on SQLite, rolling out first to in-memory contexts such as Incognito (targeted at Chrome 145) and then to newly created on-disk stores (proposed for Chrome 156), with migration of existing data as a later step. The web-facing API doesn't change. Firefox and Safari already use SQLite-based backends. Performance characteristics of Chromium's backend may shift during the rollout, so measure on current versions rather than relying on old benchmarks.

Error handling

IndexedDB reports problems in two ways: synchronous exceptions from the method call (bad arguments, wrong state) and asynchronous error events on requests, which then abort the transaction. Promise wrappers turn both into rejections, but the synchronous ones still escape before any promise exists, which is why idb's README warns that methods "may throw instead of returning a promise". The DOMException names the spec uses:

Name Typical cause What to do
AbortError The request's transaction aborted: another request failed, abort() was called, a listener threw, or (on open()) the upgrade aborted Look at tx.error for the root cause
ConstraintError add() with an existing key, a unique index violation, a duplicate store or index name, or an exhausted key generator Use put(), or cancel the event with preventDefault() to continue
DataCloneError The value can't be serialized: functions, Proxy objects, DOM nodes, SharedArrayBuffer Convert to plain data first (toRaw(), toJSON() or a mapper)
DataError Invalid key or range, missing in-line key, a key argument passed to an in-line store, a backwards continue(key) Validate keys (indexedDB.cmp()), check key paths
InvalidAccessError autoIncrement with an empty or array key path, multiEntry with an array key path, an empty store list in transaction() Fix the schema definition
InvalidStateError Using a closed or closing connection, schema methods outside an upgrade, transaction() during an upgrade, a deleted store, double continue() Reopen the connection, restructure the code
NotFoundError transaction(), objectStore() or index() with a name that doesn't exist in this connection's schema Old code against a new schema, or a typo
NotReadableError The stored value can't be read: in Chromium 132+, a large value's backing file is missing Delete the record and re-fetch
QuotaExceededError Quota exhausted, usually reported when the transaction commits See Storage Quotas & Persistence
ReadOnlyError A write in a readonly transaction Open the transaction with "readwrite"
SyntaxError Invalid key path Fix the key path string
TransactionInactiveError A request placed on an inactive or finished transaction, usually after awaiting non-IndexedDB work Move async work outside the transaction
UnknownError Transient I/O errors, lost backing-store connections Reopen and retry once, then report
VersionError open() with a lower version than the stored one Old code is running: prompt a reload
SecurityError open() in a context without a usable storage key (opaque origins) or with storage blocked Fall back to in-memory state

A defensive open() wrapper distinguishes "IndexedDB is unavailable here" from real errors, so the app can degrade to memory-only mode instead of failing to boot:

safe-open.js
import { getDB } from "./connection.js";

/** Returns a connection, or null when this context can't use IndexedDB at all. */
export async function openOrNull() {
  if (typeof indexedDB === "undefined") return null;
  try {
    return await getDB();
  } catch (error) {
    if (error?.name === "SecurityError" || error?.name === "InvalidStateError") {
      reportOnce("indexeddb-unavailable", error); // opaque origin, blocked storage, restricted mode
      return null;
    }
    throw error; // VersionError, AbortError, UnknownError: real problems worth surfacing
  }
}

const reported = new Set();
function reportOnce(key, error) {
  if (reported.has(key)) return;
  reported.add(key);
  console.warn(`[storage] ${key}:`, error);
}

For monitoring, log error.name and error.message together with the operation, store name and the browser's user agent. Engines use the same name for several causes, and the spec encourages implementations to put the specific cause in message.

The raw API vs the idb library

Every example on this page shows both styles because both are in wide production use. idb is Jake Archibald's thin wrapper, currently 8.0.3. It proxies the real IndexedDB objects and changes only a few things:

Raw IndexedDB idb
indexedDB.open() + upgradeneeded, blocked, success, error events openDB(name, version, { upgrade, blocked, blocking, terminated }) returns a promise
db.onversionchange The blocking(currentVersion, blockedVersion, event) callback
db.onclose The terminated() callback
request.onsuccess / onerror Every method that returns an IDBRequest returns a promise instead
tx.oncomplete / onabort tx.done promise, which rejects with tx.error
tx.objectStore(name) for single-store transactions tx.store shortcut
One-off transactions db.get(), db.put(), db.getAllFromIndex() and similar, each in its own transaction
Cursor success loop await cursor.continue(), or for await (const cursor of store.iterate(range))
Getting at the raw object unwrap(wrapped), and wrap(raw) for the reverse

Things idb deliberately does not change: transaction lifetime (awaiting fetch() mid-transaction still breaks it), scheduling, durability (pass { durability } as the third transaction() argument, since arguments pass through untouched), and the schema model. New IndexedDB methods such as getAllRecords() work through idb automatically, because it wraps any function that returns an IDBRequest.

Two idb-specific pitfalls:

  • Unawaited request promises. tx.store.put(a); tx.store.put(b); await tx.done; works, but if the transaction aborts, the two put() promises reject with nobody listening, and you get unhandledrejection noise. Use await Promise.all([tx.store.put(a), tx.store.put(b), tx.done]).
  • Async upgrade() callbacks. idb calls upgrade() from the upgradeneeded listener and ignores its return value. Errors thrown after the first await don't abort the upgrade. Catch them and call tx.abort(), as in the migration example above.

With TypeScript, idb's DBSchema type gives you typed stores, keys, values and index names:

schema.ts
import { openDB, type DBSchema } from "idb";

export interface Note {
  id: string;
  folderId: string;
  title: string;
  body: string;
  tags: string[];
  updatedAt: number;
  deleted: 0 | 1;
  dirtySince?: number;
  syncError?: string;
}

interface NotesDB extends DBSchema {
  notes: {
    key: string;
    value: Note;
    indexes: {
      "by-updatedAt": number;
      "by-folder-updatedAt": [string, number, string];
      "by-tag": string; // multiEntry: the type of each element
      "by-dirty": number;
    };
  };
  outbox: { key: number; value: OutboxEntry; indexes: { "by-note": string } };
  "outbox-dead": { key: number; value: OutboxEntry & { deadAt: number } };
  meta: { key: string; value: unknown };
}

export interface OutboxEntry {
  seq?: number;
  noteId: string;
  method: "PUT" | "DELETE";
  url: string;
  body?: unknown;
  idempotencyKey: string;
  attempts: number;
  createdAt: number;
  nextAttemptAt: number;
  lastError: string | null;
}

export const dbPromise = openDB<NotesDB>("notes-app", 3, {
  upgrade(db) {
    // db.createObjectStore("notez") is now a compile-time error.
  },
});

Alternatives: Dexie, localForage and RxDB

Library Latest version (npm, Sept 2026) What it adds Consider when
idb 8.0.3 (May 2025) Promises, tx.done, async iterators, TypeScript schema types You want IndexedDB's model with less boilerplate
Dexie.js 4.4.6 (September 2026) Declarative schema strings, a query builder (where().between(), orderBy(), filter()), bulk operations, liveQuery() observables, an optional sync service (Dexie Cloud) You want a higher-level query API and reactive queries across tabs
localForage 1.10.0 (August 2021) A localStorage-like async key-value API over IndexedDB, with WebSQL and localStorage fallbacks Maintaining existing code. No release since 2021, and its WebSQL fallback targets an API Chromium has removed
RxDB 17.5.0 (August 2026) A reactive NoSQL database with JSON schemas, observable queries and replication plugins, over pluggable storage You need a full local-first database with replication. Its Dexie-based storage is free; its native IndexedDB and OPFS storages are premium plugins

Dexie expresses the schema from this page in one line per store, with ++ for auto-increment keys, & for unique indexes, * for multiEntry and [a+b] for compound indexes:

dexie-db.js
import Dexie, { liveQuery } from "dexie";

export const db = new Dexie("notes-app-dexie");
db.version(1).stores({
  notes: "id, updatedAt, [folderId+updatedAt], *tags, dirtySince",
  outbox: "++seq, noteId",
});

// Newest notes in a folder, using the compound index.
export function recentInFolder(folderId, limit = 50) {
  return db.notes
    .where("[folderId+updatedAt]")
    .between([folderId, Dexie.minKey], [folderId, Dexie.maxKey])
    .reverse()
    .limit(limit)
    .toArray();
}

// Re-runs automatically when a transaction in any tab changes matching data.
export const urgent$ = liveQuery(() => db.notes.where("tags").equals("urgent").toArray());

// Atomic write across two tables.
export function saveWithOutbox(note, entry) {
  return db.transaction("rw", db.notes, db.outbox, async () => {
    await db.notes.put(note);
    await db.outbox.add(entry);
  });
}

Dexie's schema string lists only indexed properties. Other properties are stored but not indexed, and indexing large strings or binary data is explicitly discouraged in its documentation. The transaction rules underneath are the same: don't call non-Dexie async APIs inside db.transaction().

If your data is relational and query-heavy (joins, aggregates, full-text search), consider SQLite compiled to WebAssembly on OPFS instead of layering a query engine on IndexedDB.

Pattern: an outbox queue for offline writes

An offline-capable app must accept writes while the network is down and deliver them later, exactly once and in order. The outbox (or transactional outbox) pattern does this with two rules:

  1. Every local change writes an outbox entry in the same IndexedDB transaction as the change itself. After a crash, either both exist or neither does. There's never a local change the server will never hear about, and never a queued request for a change that didn't happen.
  2. A flusher sends entries oldest first, deletes each one only after the server acknowledges it, and never holds a transaction open during network I/O.
sequenceDiagram
    participant UI as Page
    participant DB as IndexedDB
    participant F as Flusher in page or SW
    participant API as Server
    UI->>DB: one transaction: put note and add outbox entry
    DB-->>UI: complete
    UI->>F: request flush via Background Sync or online event
    F->>DB: read oldest outbox entry
    F->>API: PUT with Idempotency-Key
    API-->>F: 200 OK
    F->>DB: one transaction: delete entry, clear dirty flag

Design decisions baked into the implementation below:

  • Strict FIFO. The flusher always takes the lowest seq and stops at the first retryable failure, so a later edit never overtakes an earlier one. That means head-of-line blocking: one failing entry holds back everything behind it. Permanent failures (4xx other than 408 and 429) move to a dead-letter store so they can't block the queue forever.
  • Idempotency keys. A request can succeed on the server while the response is lost. The retry then carries the same key, and a server that remembers recent keys can return the original result instead of applying the change twice.
  • Exponential backoff with jitter, honoring Retry-After, capped at 30 minutes, with a maximum number of attempts.
  • A single flusher at a time, enforced with the Web Locks API (Chrome 69, Firefox 96, Safari 15.4). Tabs and the service worker can all trigger flushes safely.
  • No coalescing. Merging several queued PUTs for one record into one looks attractive, but the flusher may be sending the entry you'd merge into right now, and deleting it after success would drop your merged change. Full-state PUTs are idempotent anyway, so sending each one is correct, only slightly wasteful.
outbox.js
// Raw IndexedDB outbox. The app's upgrade creates (see open-db.js):
//   outbox       { keyPath: "seq", autoIncrement: true } + index "by-note" on "noteId"
//   outbox-dead  { keyPath: "seq" }

const MAX_ATTEMPTS = 8;
const BASE_DELAY_MS = 5_000;
const MAX_DELAY_MS = 30 * 60_000;

/**
 * Queues a request inside the caller's readwrite transaction, so the local change
 * and the intent to sync it commit together or not at all.
 */
export function enqueue(tx, { noteId, method, url, body }) {
  const now = Date.now();
  return tx.objectStore("outbox").add({
    noteId,
    method,
    url,
    body,
    idempotencyKey: crypto.randomUUID(),
    attempts: 0,
    createdAt: now,
    nextAttemptAt: now,
    lastError: null,
  });
}

/** Sends queued requests oldest-first. Safe to call from any tab or the service worker. */
export async function flushOutbox(db, { fetchImpl = fetch } = {}) {
  const run = () => drain(db, fetchImpl);
  if (!globalThis.navigator?.locks) return run();
  // One flusher per origin; everyone else returns immediately instead of queueing.
  return navigator.locks.request("outbox-flush", { ifAvailable: true }, (lock) =>
    lock ? run() : { sent: 0, pending: true, skipped: true },
  );
}

async function drain(db, fetchImpl) {
  let sent = 0;
  for (;;) {
    const [head] = await readOldest(db);
    if (!head) return { sent, pending: false };
    if (head.nextAttemptAt > Date.now()) {
      return { sent, pending: true, retryAt: head.nextAttemptAt }; // still backing off
    }
    const outcome = await deliver(head, fetchImpl); // no transaction is open here
    await settle(db, head, outcome);
    if (outcome.ok) sent++;
    else if (outcome.retryable) return { sent, pending: true, error: outcome.error };
    // Permanent failure: settle() dead-lettered it. Continue with the next entry.
  }
}

function readOldest(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("outbox");
    const request = tx.objectStore("outbox").getAll(null, 1); // lowest seq first
    tx.oncomplete = () => resolve(request.result);
    tx.onabort = () => reject(tx.error);
  });
}

async function deliver(entry, fetchImpl) {
  let response;
  try {
    response = await fetchImpl(entry.url, {
      method: entry.method,
      headers: { "Content-Type": "application/json", "Idempotency-Key": entry.idempotencyKey },
      body: entry.body === undefined ? undefined : JSON.stringify(entry.body),
    });
  } catch (error) {
    return { ok: false, retryable: true, error: `Network error: ${error.message}` };
  }
  if (response.ok) return { ok: true };
  return {
    ok: false,
    retryable: response.status >= 500 || response.status === 408 || response.status === 429,
    error: `HTTP ${response.status}`,
    retryAfterMs: parseRetryAfter(response.headers.get("Retry-After")),
  };
}

function settle(db, entry, outcome) {
  return new Promise((resolve, reject) => {
    // Strict: an acknowledged entry must not reappear after a crash and be re-sent needlessly,
    // and a dead-lettered entry must not vanish.
    const tx = db.transaction(["outbox", "outbox-dead"], "readwrite", { durability: "strict" });
    const outbox = tx.objectStore("outbox");
    const attempts = entry.attempts + 1;

    if (outcome.ok) {
      outbox.delete(entry.seq);
    } else if (!outcome.retryable || attempts >= MAX_ATTEMPTS) {
      outbox.delete(entry.seq); // move to dead letters atomically
      tx.objectStore("outbox-dead").put({ ...entry, attempts, lastError: outcome.error, deadAt: Date.now() });
    } else {
      outbox.put({
        ...entry,
        attempts,
        lastError: outcome.error,
        nextAttemptAt: Date.now() + backoffDelay(attempts, outcome.retryAfterMs ?? 0),
      });
    }
    tx.oncomplete = () => resolve();
    tx.onabort = () => reject(tx.error);
  });
}

function backoffDelay(attempts, retryAfterMs) {
  const exponential = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempts - 1));
  const jittered = exponential / 2 + Math.random() * (exponential / 2); // spread retries across clients
  return Math.max(jittered, retryAfterMs);
}

function parseRetryAfter(value) {
  if (!value) return 0;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); // delta-seconds form
  const date = Date.parse(value); // HTTP-date form
  return Number.isNaN(date) ? 0 : Math.max(0, date - Date.now());
}

Triggering flushes: Background Sync lets the service worker flush when connectivity returns, even after the page is closed, but only Chromium-based browsers implement it. Everywhere else, flush from the page on startup, on the online event and when the page becomes visible:

sw.js
import { getDB } from "./connection.js";
import { flushOutbox } from "./outbox.js";

self.addEventListener("sync", (event) => {
  if (event.tag !== "outbox") return;
  event.waitUntil(
    (async () => {
      const result = await flushOutbox(await getDB());
      // Rejecting asks the browser to retry this sync later with its own backoff.
      if (result.pending && !result.skipped) throw new Error(result.error ?? "Outbox not drained");
    })(),
  );
});
app.js
import { getDB } from "./connection.js";
import { flushOutbox } from "./outbox.js";

export async function requestOutboxFlush() {
  // getRegistration() resolves to undefined without a service worker; .ready would wait forever.
  const registration = await navigator.serviceWorker?.getRegistration();
  if (registration?.sync) {
    try {
      await registration.sync.register("outbox"); // Chromium: runs when online
      return;
    } catch {
      // Background Sync unavailable or denied: flush from the page instead.
    }
  }
  await flushOutbox(await getDB());
}

addEventListener("online", requestOutboxFlush);
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") requestOutboxFlush();
});
requestOutboxFlush();

For conflict handling when the server rejects or merges a change, see Offline-First Data & Sync.

Pattern: a cache metadata store

Cache Storage keeps no timestamps and has no expiry or LRU of its own (see Cache Storage API for the full ExpiringCache implementation). The IndexedDB side of that design is a good showcase of compound keys and sparse compound indexes:

  • The primary key is compound, ["cacheName", "url"], so one store serves every cache.
  • by-cache-accessed on ["cacheName", "accessedAt"] lists a cache's entries in LRU order with a prefix range.
  • by-cache-expires on ["cacheName", "expiresAt"] is sparse: entries stored without a max age have no expiresAt, so the compound key path fails to evaluate and they aren't indexed at all.
cache-meta.js
import { openDB } from "idb";

const dbPromise = openDB("cache-meta", 1, {
  upgrade(db) {
    const entries = db.createObjectStore("entries", { keyPath: ["cacheName", "url"] });
    entries.createIndex("by-cache-accessed", ["cacheName", "accessedAt"]);
    entries.createIndex("by-cache-expires", ["cacheName", "expiresAt"]); // sparse
  },
});

/** Call after cache.put(). */
export async function recordPut(cacheName, url, { size = 0, maxAgeSeconds } = {}) {
  const db = await dbPromise;
  const now = Date.now();
  const entry = { cacheName, url, size, storedAt: now, accessedAt: now };
  if (maxAgeSeconds) entry.expiresAt = now + maxAgeSeconds * 1000;
  // Relaxed: losing a metadata write in a crash only makes eviction slightly less precise.
  const tx = db.transaction("entries", "readwrite", { durability: "relaxed" });
  await Promise.all([tx.store.put(entry), tx.done]);
}

/** Call on a cache hit. Throttle it for hot entries: each call is a write. */
export async function recordHit(cacheName, url) {
  const db = await dbPromise;
  const tx = db.transaction("entries", "readwrite", { durability: "relaxed" });
  const entry = await tx.store.get([cacheName, url]);
  if (entry) await tx.store.put({ ...entry, accessedAt: Date.now() });
  await tx.done;
}

/** Deletes expired entries, then the least recently used beyond maxEntries. */
export async function enforceLimits(cacheName, { maxEntries = Infinity, now = Date.now() } = {}) {
  const db = await dbPromise;
  const victims = [];
  const tx = db.transaction("entries", "readwrite");

  const expired = IDBKeyRange.bound([cacheName, -Infinity], [cacheName, now]);
  for await (const cursor of tx.store.index("by-cache-expires").iterate(expired)) {
    victims.push(cursor.value.url);
    await cursor.delete();
  }

  // Counts inside the same transaction already reflect the deletions above.
  const inCache = IDBKeyRange.bound([cacheName], [cacheName, []]);
  let excess = (await tx.store.index("by-cache-accessed").count(inCache)) - maxEntries;
  if (excess > 0) {
    for await (const cursor of tx.store.index("by-cache-accessed").iterate(inCache)) {
      victims.push(cursor.value.url); // ascending accessedAt: least recently used first
      await cursor.delete();
      if (--excess === 0) break;
    }
  }
  await tx.done;

  // Cache Storage isn't part of the IndexedDB transaction. If the worker dies here, some
  // responses stay cached without metadata; a periodic sweep comparing cache.keys()
  // with this store removes them.
  const cache = await caches.open(cacheName);
  await Promise.all(victims.map((url) => cache.delete(url)));
  return victims.length;
}

Workbox's expiration plugin implements the same idea with its own IndexedDB database. See Advanced Workbox if you'd rather not maintain this yourself.

Full example: an offline data layer

This module ties the page together: a notes store with schema migrations, a self-healing connection, keyset pagination, tag queries, crash-safe local writes with an outbox, push and pull sync, cross-tab notifications and one flusher at a time. It runs unchanged in windows, dedicated workers and the service worker.

flowchart LR
    UI["UI components"] -->|"saveNote, listFolder"| Data["data.js"]
    SW["Service worker sync event"] -->|syncNow| Data
    Data --> IDB[("IndexedDB notes-app v3: notes, outbox, outbox-dead, meta")]
    Data -->|"PUT, DELETE with Idempotency-Key"| API["Server API"]
    API -->|"GET changes since cursor"| Data
    Data -->|"BroadcastChannel"| Tabs["Other tabs and workers"]
data.js
// Offline data layer for a notes PWA. Runs in windows, workers and the service worker.
// Dependency: idb 8 (npm install idb). Bundle it, or use a module service worker.
import { openDB } from "idb";

const DB_NAME = "notes-app";
const DB_VERSION = 3;
const MAX_ATTEMPTS = 8;
const BASE_DELAY_MS = 5_000;
const MAX_DELAY_MS = 30 * 60_000;

// ---------------------------------------------------------------- events

const channel = new BroadcastChannel("notes-app:changes");
const events = new EventTarget();
channel.onmessage = (message) => events.dispatchEvent(new CustomEvent("event", { detail: message.data }));

/** Notifies this context and, for data changes, every other context of the origin. */
function emit(detail, { broadcast = false } = {}) {
  events.dispatchEvent(new CustomEvent("event", { detail }));
  if (broadcast) channel.postMessage(detail); // BroadcastChannel never delivers to its sender
}

/** Subscribes to data and lifecycle events. Returns an unsubscribe function. */
export function subscribe(callback) {
  const listener = (event) => callback(event.detail);
  events.addEventListener("event", listener);
  return () => events.removeEventListener("event", listener);
}

// ------------------------------------------------------------ connection

let dbPromise = null;

export function getDB() {
  dbPromise ??= openDB(DB_NAME, DB_VERSION, {
    upgrade: migrate,
    blocked: () => emit({ type: "update-blocked" }), // another tab keeps an old version open
    blocking: (currentVersion, blockedVersion, event) => {
      event.target.close(); // let the newer version in
      dbPromise = null;
      emit({ type: "schema-outdated", version: blockedVersion });
    },
    terminated: () => {
      dbPromise = null; // abnormal close: storage cleared, I/O error
    },
  }).catch((error) => {
    dbPromise = null; // don't cache failures
    if (error.name === "VersionError") emit({ type: "schema-outdated" }); // this code is older than the data
    throw error;
  });
  return dbPromise;
}

/** Closes deliberately (pagehide, logout). The next call reopens. */
export async function closeDB() {
  const pending = dbPromise;
  dbPromise = null;
  const db = await pending?.catch(() => null);
  db?.close();
}

async function migrate(db, oldVersion, newVersion, tx) {
  try {
    if (oldVersion < 1) {
      const notes = db.createObjectStore("notes", { keyPath: "id" });
      notes.createIndex("by-updatedAt", "updatedAt");
    }
    if (oldVersion < 2) {
      const outbox = db.createObjectStore("outbox", { keyPath: "seq", autoIncrement: true });
      outbox.createIndex("by-note", "noteId");
      db.createObjectStore("outbox-dead", { keyPath: "seq" });
      db.createObjectStore("meta");
    }
    if (oldVersion < 3) {
      const notes = tx.objectStore("notes");
      notes.createIndex("by-folder-updatedAt", ["folderId", "updatedAt", "id"]);
      notes.createIndex("by-tag", "tags", { multiEntry: true });
      notes.createIndex("by-dirty", "dirtySince"); // sparse: only notes with unpushed edits
      for await (const cursor of notes) {
        if (typeof cursor.value.tags === "string") {
          await cursor.update({ ...cursor.value, tags: normalizeTags(cursor.value.tags) });
        }
      }
    }
  } catch (error) {
    try {
      tx.abort(); // roll the whole upgrade back; open() rejects with AbortError
    } catch {
      // already aborted
    }
    throw error;
  }
}

/**
 * Runs work(tx) in one transaction and resolves after it commits.
 * - Reopens once if the connection died before the transaction could be created.
 * - Aborts if work throws, so partial writes never commit.
 */
async function inTransaction(storeNames, mode, work, options) {
  let tx;
  try {
    tx = (await getDB()).transaction(storeNames, mode, options);
  } catch (error) {
    if (error.name !== "InvalidStateError") throw error;
    dbPromise = null; // closed under us, e.g. after the app was suspended
    tx = (await getDB()).transaction(storeNames, mode, options);
  }
  const done = tx.done;
  done.catch(() => {}); // observed below; avoids a spurious unhandledrejection on abort
  try {
    const result = await work(tx);
    await done;
    return result;
  } catch (error) {
    try {
      tx.abort();
    } catch {
      // already committed or aborted
    }
    throw error;
  }
}

// ----------------------------------------------------------------- reads

export function getNote(id) {
  return inTransaction("notes", "readonly", async (tx) => {
    const note = await tx.store.get(id);
    return note && !note.deleted ? note : undefined;
  });
}

/** Newest-first page of a folder. Pass the returned `next` to fetch the following page. */
export function listFolder(folderId, { next = null, limit = 50 } = {}) {
  return inTransaction("notes", "readonly", async (tx) => {
    const upper = next ? [folderId, next.updatedAt, next.id] : [folderId, []];
    const range = IDBKeyRange.bound([folderId], upper, false, next !== null);
    const notes = [];
    for await (const cursor of tx.store.index("by-folder-updatedAt").iterate(range, "prev")) {
      if (!cursor.value.deleted) notes.push(cursor.value);
      if (notes.length === limit) break;
    }
    const last = notes.at(-1);
    return { notes, next: notes.length === limit ? { updatedAt: last.updatedAt, id: last.id } : null };
  });
}

export function listByTag(tag) {
  const [key] = normalizeTags([tag]);
  if (!key) return Promise.resolve([]);
  return inTransaction("notes", "readonly", async (tx) => {
    const notes = await tx.store.index("by-tag").getAll(IDBKeyRange.only(key));
    return notes.filter((note) => !note.deleted);
  });
}

// ---------------------------------------------------------------- writes

export async function saveNote(input) {
  const note = await inTransaction(
    ["notes", "outbox"],
    "readwrite",
    async (tx) => {
      const notes = tx.objectStore("notes");
      const id = input.id ?? crypto.randomUUID();
      const existing = await notes.get(id);
      const now = Date.now();
      const { syncError, ...base } = { folderId: "inbox", title: "", body: "", ...existing, ...input };
      const next = {
        ...base,
        id,
        tags: normalizeTags(input.tags ?? existing?.tags),
        updatedAt: now,
        deleted: 0,
        dirtySince: existing?.dirtySince ?? now,
      };
      await notes.put(next);
      await tx.objectStore("outbox").add(outboxEntry(id, "PUT", toWire(next)));
      return next;
    },
    { durability: "strict" }, // the only copy of the user's edit until the server has it
  );
  emit({ type: "notes-changed", ids: [note.id] }, { broadcast: true });
  return note;
}

export async function deleteNote(id) {
  const deleted = await inTransaction(
    ["notes", "outbox"],
    "readwrite",
    async (tx) => {
      const notes = tx.objectStore("notes");
      const existing = await notes.get(id);
      if (!existing || existing.deleted) return false;
      const now = Date.now();
      // Tombstone until the server confirms, so a pull can't resurrect the note.
      await notes.put({ ...existing, deleted: 1, updatedAt: now, dirtySince: existing.dirtySince ?? now });
      await tx.objectStore("outbox").add(outboxEntry(id, "DELETE", undefined));
      return true;
    },
    { durability: "strict" },
  );
  if (deleted) emit({ type: "notes-changed", ids: [id] }, { broadcast: true });
  return deleted;
}

// ------------------------------------------------------------------ sync

/** Pushes local edits, then pulls remote changes. One sync per origin at a time. */
export function syncNow({ fetchImpl = fetch } = {}) {
  const run = async () => {
    const pushed = await pushChanges(fetchImpl);
    const pulled = await pullChanges(fetchImpl); // skips notes that still have unpushed edits
    return { ...pushed, pulled };
  };
  if (!globalThis.navigator?.locks) return run();
  return navigator.locks.request("notes-app:sync", { ifAvailable: true }, (lock) =>
    lock ? run() : { skipped: true, pending: true },
  );
}

async function pushChanges(fetchImpl) {
  let sent = 0;
  for (;;) {
    const [head] = await inTransaction("outbox", "readonly", (tx) => tx.store.getAll(null, 1));
    if (!head) return { sent, pending: false };
    if (head.nextAttemptAt > Date.now()) return { sent, pending: true, retryAt: head.nextAttemptAt };
    const outcome = await deliver(head, fetchImpl); // no transaction is open during network I/O
    await settle(head, outcome);
    if (outcome.ok) sent++;
    else if (outcome.retryable) return { sent, pending: true, error: outcome.error };
  }
}

function settle(entry, outcome) {
  return inTransaction(
    ["outbox", "outbox-dead", "notes"],
    "readwrite",
    async (tx) => {
      const outbox = tx.objectStore("outbox");
      const attempts = entry.attempts + 1;
      if (outcome.retryable && attempts < MAX_ATTEMPTS) {
        const nextAttemptAt = Date.now() + backoffDelay(attempts, outcome.retryAfterMs);
        await outbox.put({ ...entry, attempts, lastError: outcome.error, nextAttemptAt });
        return;
      }
      await outbox.delete(entry.seq);
      if (!outcome.ok) {
        await tx.objectStore("outbox-dead").put({ ...entry, attempts, lastError: outcome.error, deadAt: Date.now() });
      }
      if ((await outbox.index("by-note").count(entry.noteId)) > 0) return; // more edits still queued

      const notes = tx.objectStore("notes");
      const note = await notes.get(entry.noteId);
      if (!note) return;
      if (!outcome.ok) {
        // Keep dirtySince so pulls don't overwrite the local copy; the UI can offer a retry or discard.
        await notes.put({ ...note, syncError: outcome.error });
      } else if (note.deleted) {
        await notes.delete(note.id); // server confirmed the delete: drop the tombstone
      } else {
        const { dirtySince, syncError, ...clean } = note;
        await notes.put(clean);
      }
    },
    { durability: "strict" },
  );
}

async function pullChanges(fetchImpl) {
  const since = (await inTransaction("meta", "readonly", (tx) => tx.store.get("pullCursor"))) ?? "";
  const response = await fetchImpl(`/api/notes/changes?since=${encodeURIComponent(since)}`, {
    headers: { Accept: "application/json" },
  });
  if (!response.ok) throw new Error(`Pull failed: HTTP ${response.status}`);
  const { changes, cursor } = await response.json(); // parse before the transaction starts

  const changedIds = await inTransaction(
    ["notes", "meta"],
    "readwrite",
    async (tx) => {
      const notes = tx.objectStore("notes");
      const dirty = new Set(await notes.index("by-dirty").getAllKeys()); // primary keys of unpushed notes
      const writes = [];
      const ids = [];
      for (const remote of changes) {
        if (dirty.has(remote.id)) continue; // local edits win until they're pushed
        writes.push(remote.deleted ? notes.delete(remote.id) : notes.put(fromWire(remote)));
        ids.push(remote.id);
      }
      writes.push(tx.objectStore("meta").put(cursor, "pullCursor")); // atomic with the data
      await Promise.all(writes);
      return ids;
    },
    { durability: "relaxed" }, // a lost pull is simply fetched again
  );
  if (changedIds.length > 0) emit({ type: "notes-changed", ids: changedIds }, { broadcast: true });
  return changedIds.length;
}

// --------------------------------------------------------------- helpers

function outboxEntry(noteId, method, body) {
  const now = Date.now();
  return {
    noteId,
    method,
    url: `/api/notes/${encodeURIComponent(noteId)}`,
    body,
    idempotencyKey: crypto.randomUUID(),
    attempts: 0,
    createdAt: now,
    nextAttemptAt: now,
    lastError: null,
  };
}

async function deliver(entry, fetchImpl) {
  let response;
  try {
    response = await fetchImpl(entry.url, {
      method: entry.method,
      headers: { "Content-Type": "application/json", "Idempotency-Key": entry.idempotencyKey },
      body: entry.body === undefined ? undefined : JSON.stringify(entry.body),
    });
  } catch (error) {
    return { ok: false, retryable: true, error: `Network error: ${error.message}`, retryAfterMs: 0 };
  }
  if (response.ok) return { ok: true };
  return {
    ok: false,
    retryable: response.status >= 500 || response.status === 408 || response.status === 429,
    error: `HTTP ${response.status}`,
    retryAfterMs: parseRetryAfter(response.headers.get("Retry-After")),
  };
}

function backoffDelay(attempts, retryAfterMs = 0) {
  const exponential = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempts - 1));
  return Math.max(exponential / 2 + Math.random() * (exponential / 2), retryAfterMs);
}

function parseRetryAfter(value) {
  if (!value) return 0;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
  const date = Date.parse(value);
  return Number.isNaN(date) ? 0 : Math.max(0, date - Date.now());
}

function normalizeTags(tags) {
  const list = Array.isArray(tags) ? tags : String(tags ?? "").split(",");
  return [...new Set(list.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean))];
}

function toWire({ id, folderId, title, body, tags, updatedAt }) {
  return { id, folderId, title, body, tags, updatedAt };
}

function fromWire(remote) {
  return {
    id: remote.id,
    folderId: remote.folderId ?? "inbox",
    title: remote.title ?? "",
    body: remote.body ?? "",
    tags: normalizeTags(remote.tags),
    updatedAt: remote.updatedAt,
    deleted: 0,
  };
}

Wiring it up takes a few lines in each context:

app.js
import { closeDB, listFolder, saveNote, subscribe, syncNow } from "./data.js";

subscribe((event) => {
  if (event.type === "notes-changed") refreshVisibleNotes(event.ids);
  if (event.type === "schema-outdated") showReloadBanner();
  if (event.type === "update-blocked") showToast("Close other tabs of this app to finish updating.");
});

async function requestSync() {
  const registration = await navigator.serviceWorker?.getRegistration();
  if (registration?.sync) {
    try {
      await registration.sync.register("notes-sync"); // Chromium: also retried after the tab closes
      return;
    } catch {
      // fall through to an in-page sync
    }
  }
  await syncNow().catch((error) => console.warn("Sync deferred:", error.message)); // offline, server down
}

document.querySelector("#editor").addEventListener("submit", async (event) => {
  event.preventDefault();
  const form = new FormData(event.currentTarget);
  await saveNote({ id: form.get("id") || undefined, title: form.get("title"), body: form.get("body") });
  requestSync(); // the note is safe locally whether or not this succeeds
});

addEventListener("online", requestSync);
addEventListener("pagehide", () => closeDB()); // keep the page eligible for the bfcache
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") requestSync();
});

const { notes } = await listFolder("inbox");
renderNotes(notes);
requestSync();
sw.js
// Register with navigator.serviceWorker.register("/sw.js", { type: "module" }), or bundle.
import { syncNow } from "./data.js";

self.addEventListener("sync", (event) => {
  if (event.tag !== "notes-sync") return;
  event.waitUntil(
    syncNow().then((result) => {
      // Rejecting makes the browser schedule another attempt later.
      if (result.pending && !result.skipped) throw new Error(result.error ?? "Outbox not drained");
    }),
  );
});

What this design guarantees:

  • A note the user saved is never lost by a crash, reload or closed tab: it's committed with strict durability together with its outbox entry before saveNote() resolves.
  • The server sees each note's edits in order, at least once, and with idempotency keys so it can deduplicate.
  • A pull never overwrites unpushed local edits (the sparse by-dirty index finds them with one getAllKeys()), and the pull cursor advances atomically with the data it describes.
  • Other tabs learn about changes only after they're committed, and exactly one context syncs at a time.

Browser support

IndexedDB itself has been available in every browser engine for over a decade, so the practical question is which additions you can use. Support data as of September 2026. See MDN's IndexedDB compatibility tables and caniuse for live data.

Feature Chrome / Edge Firefox Safari (macOS / iOS)
IndexedDB, unprefixed ✅ 24 ✅ 16 ✅ 8
In workers and service workers ✅ ✅ 37 ✅ 10
getAll(), getAllKeys(), openKeyCursor() on stores ✅ 48 ✅ 44 ✅ 10.1
Renaming stores and indexes ✅ 55 ✅ 49 ✅ 10.1
continuePrimaryKey() ✅ 58 ✅ 10 ✅ 10.1
indexedDB.databases() ✅ 72 ✅ 126 ✅ 14
IDBTransaction.commit() ✅ 76 ✅ 74 ✅ 15
IDBCursor.request ✅ 76 ✅ 77 ✅ 15
durability option ✅ 83 ⚠️ attribute from 126 ✅ 15
Relaxed durability by default ✅ 121 ✅ ✅
NotReadableError for lost large values ✅ 132 n/a n/a
getAllRecords() and IDBRecord ✅ 141 ✅ 153 🧪 Technology Preview
Options dictionary and direction in getAll() / getAllKeys() ✅ 141 ❌ ❌

⚠️ Firefox added the IDBTransaction.durability attribute in Firefox 126. The Chrome team's durability announcement describes Firefox and Safari as already using relaxed durability by default. Early Safari 14 releases had a bug in which the first indexedDB.open() could hang forever (WebKit bug 226547), which is only relevant if you still support those versions.

In private browsing, IndexedDB works but is temporary: its data is discarded when the private session ends, and quotas can differ from normal browsing. Firefox has supported it there since Firefox 115, using encrypted on-disk storage whose keys live only in memory. See Storage Quotas & Persistence and Privacy & Storage Partitioning for quota and partitioning rules.

Common pitfalls

  • Awaiting non-IndexedDB promises inside a transaction. fetch(), timers, crypto.subtle, navigator.locks and worker round trips all let the transaction commit. Do async work first, or use two transactions with an optimistic check.
  • Treating a request's success as a commit. Only complete means the data is stored. Quota errors and aborts arrive later.
  • Assuming a synchronous throw aborts the transaction. It doesn't. Earlier requests still commit unless you call abort().
  • Ignoring versionchange. Old tabs and old service workers then block every future upgrade, and users see the new version hang.
  • Editing old migrations, or using switch without fall-through. Users jump from any old version to the newest. Use cumulative if (oldVersion < N) blocks and never change shipped ones.
  • Indexing booleans or null. They're not valid keys, so those records silently vanish from the index. Store 0/1 or use sparse properties.
  • Storing framework state directly. Proxy-based reactive objects throw DataCloneError. Store plain data.
  • Relying on class instances. They come back as plain objects without methods.
  • Auto-increment IDs for synced records. They collide across devices. Use crypto.randomUUID().
  • One transaction per record in a loop. Batch writes into one transaction, or a few large ones.
  • Base64 or ArrayBuffer for media. Store Blobs, in a separate store from queryable metadata.
  • Holding a connection forever on mobile. Reset on close, versionchange and InvalidStateError, and close on pagehide.
  • Assuming the data is permanent. Best-effort storage can be evicted as a whole origin, and Safari's 7-day cap applies to IndexedDB outside Home Screen web apps. Request persistent storage for data that matters, and keep the server as the source of truth.

Debugging

Chrome and Edge DevTools. Application › Storage › IndexedDB lists databases per origin and storage bucket, with each database's version, object stores and indexes. You can browse records by key, start from a key, and delete individual records, clear a store or delete the database. Clearing a store asks for confirmation since Chrome 139. The view doesn't update live, so use the refresh button after writes. Application › Storage shows usage per storage type and can clear site data or simulate a custom quota. See View and change IndexedDB data and Browser DevTools.

Firefox. The Storage Inspector (Storage › Indexed DB) shows databases, stores, indexes and records, and can delete them. See the Firefox IndexedDB storage inspector docs.

Safari. Web Inspector's Storage tab shows IndexedDB databases for the inspected page. For an installed Home Screen web app on iOS, connect the device to a Mac and inspect the web app from Safari's Develop menu, because its storage is separate from Safari's.

Console snippets that work in any engine:

console-snippets.js
// List databases and versions.
await indexedDB.databases();

// Dump a store as a table (paste into the console of the app's origin).
const dump = (dbName, storeName) =>
  new Promise((resolve, reject) => {
    const open = indexedDB.open(dbName); // no version: never triggers an upgrade
    open.onerror = () => reject(open.error);
    open.onsuccess = () => {
      const db = open.result;
      const request = db.transaction(storeName).objectStore(storeName).getAll();
      request.onsuccess = () => {
        console.table(request.result);
        db.close(); // don't block the app's next upgrade
        resolve(request.result.length);
      };
      request.onerror = () => reject(request.error);
    };
  });
await dump("notes-app", "outbox");

Transaction-lifetime bugs show up as TransactionInactiveError. Set a breakpoint on the throwing call and look for an await of something other than an IndexedDB request between the transaction's creation and that line. Blocked upgrades show up as an open() that never settles. Log blocked events, and check for other tabs, iframes and the service worker holding connections.

Automated tests can run against the fake-indexeddb package (6.2.5) in Node, which implements the spec in memory. Run anything that depends on engine behavior (quota, durability, Blob storage, eviction) in real browsers. See Automated Testing.

Further reading

On this site

External references