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
versionchangetransaction that runs when you open a database with a higher version. Every connection still open on the old version receivesversionchangeand must close, or the upgrade staysblocked. - 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 throwTransactionInactiveError. - 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 usedurability: "relaxed"(Chrome's default since 121) for data you can re-fetch. - Store binary data as
Blobs, never base64 strings or largeArrayBuffers, and handleNotReadableErrorfor Blob files lost from disk (Chromium 132+). - The raw API is event-based.
idb8 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.
localStorageis 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 aTypeErrorsynchronously. The version parameter uses Web IDL[EnforceRange], soNaNandInfinityalso throw aTypeError, 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 aSecurityErrorin contexts without a usable storage key, such as opaque origins (data:URLs, sandboxed iframes withoutallow-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
instanceofchecks 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)throwsDataCloneErrorbecause the value is aProxy. 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
-Infinityis the lowest possible key.NaNisn't a valid key. - Dates sort by time value. An invalid
Dateisn'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 exampletitle.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:
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()andclear()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.
// 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:
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 ondirtySincecontains only records that have that property. unique: true. A write that would create a duplicate index key fails withConstraintError, 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, andopen()fails with anAbortError.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()throwsInvalidAccessErrorif the key path is an array andmultiEntryistrue. - Index names are unique per store. Creating a duplicate throws
ConstraintError. Rename withindex.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>]:
// 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:
// 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 ofoutboxtoo. 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:
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.- Before a request's
successorerrorevent is dispatched, the transaction becomes active again. After dispatch it becomes inactive. If an event listener threw an exception, the transaction aborts with anAbortError, even if the request succeeded. - After dispatch, if the transaction has no pending requests, it starts committing.
- You can place requests only while it is active. Otherwise the call throws
TransactionInactiveErrorsynchronously.
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.
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
// 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);
The raw examples on this page use two small helpers. They are the entire "promise layer" most apps need:
/** 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:
/**
* 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" withadd(): treatConstraintErroras expected and keep going. - If any listener throws, the transaction aborts with an
AbortError, even ifpreventDefault()was called.
/** 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 beforecompletefires."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.
// 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:
blocked(only if an upgrade or delete is needed and other connections stay open after receivingversionchange).upgradeneeded, ifversionis greater than the stored version.event.oldVersionis the stored version (0 for a new database),event.newVersionthe requested one, andrequest.transactiontheversionchangetransaction.success, after the upgrade transaction completes, withrequest.resultas the connection. Orerror, withVersionError(lower version) orAbortError(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
autoIncrementflag. 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'tawaitnon-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 andopen()fails withAbortError. That's the safety net for data migrations: fail loudly rather than half-migrate.
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...
});
}
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. CatchVersionErrorand 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:
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 theversionchangelistener, then decide: reload ifdocument.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
VersionErrorif 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:
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:
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:
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:
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(<= keyforprev). Jumping backwards throwsDataError.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)skipsnrecords. It still walks them internally, soadvance(10000)isn't a cheapOFFSET.update(value)anddelete()modify the current record (readwrite transactions only).update()on an in-line store throwsDataErrorif the new value's key doesn't match.- Calling
continue()oradvance()twice before the nextsuccessevent throwsInvalidStateError. 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:
/** 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);
});
}
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:
/** 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
BloboverArrayBuffer. AnArrayBufferis serialized inline with the record and fully copied into memory on everyget(),getAll()or cursor step. A storedBlobdeserializes as a lightweight handle, and its bytes are read only when you callblob.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":
/** 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:
/** 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);
}
});
}
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
metastore, in the same transaction as each chunk) to make the import resumable.
Read in bulk, and read less¶
getAll()with acountbeats 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()andcount()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:
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 twoput()promises reject with nobody listening, and you getunhandledrejectionnoise. Useawait Promise.all([tx.store.put(a), tx.store.put(b), tx.done]). - Async
upgrade()callbacks.idbcallsupgrade()from theupgradeneededlistener and ignores its return value. Errors thrown after the firstawaitdon't abort the upgrade. Catch them and calltx.abort(), as in the migration example above.
With TypeScript, idb's DBSchema type gives you typed stores, keys, values and index names:
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:
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:
- 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.
- 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
seqand 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-statePUTs are idempotent anyway, so sending each one is correct, only slightly wasteful.
// 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:
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");
})(),
);
});
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-accessedon["cacheName", "accessedAt"]lists a cache's entries in LRU order with a prefix range.by-cache-expireson["cacheName", "expiresAt"]is sparse: entries stored without a max age have noexpiresAt, so the compound key path fails to evaluate and they aren't indexed at all.
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"] // 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:
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();
// 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-dirtyindex finds them with onegetAllKeys()), 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.locksand worker round trips all let the transaction commit. Do async work first, or use two transactions with an optimistic check. - Treating a request's
successas a commit. Onlycompletemeans 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
switchwithout fall-through. Users jump from any old version to the newest. Use cumulativeif (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. Store0/1or use sparse properties. - Storing framework state directly.
Proxy-based reactive objects throwDataCloneError. 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
ArrayBufferfor media. StoreBlobs, in a separate store from queryable metadata. - Holding a connection forever on mobile. Reset on
close,versionchangeandInvalidStateError, and close onpagehide. - 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:
// 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
- Storage Quotas & Persistence: quota rules, eviction,
persist()and Storage Buckets - Cache Storage API: the response cache that IndexedDB metadata makes manageable
- Origin Private File System: large files, synchronous I/O and SQLite
- Offline-First Data & Sync: conflict resolution and sync architecture
- Background Sync: flushing the outbox after the page closes
- Messaging & the Clients API: coordinating pages and the service worker
- Updating Service Workers: shipping schema changes alongside new workers
- Privacy & Storage Partitioning: storage keys in third-party contexts
External references
- Indexed Database API 3.0, W3C Editor's Draft
- MDN: IndexedDB API and Using IndexedDB
- HTML Standard: perform a microtask checkpoint, where transactions are deactivated
- Chrome for Developers: A change to the default durability mode in IndexedDB
- Chrome Platform Status: getAllRecords() and direction for getAll()
- idb on GitHub, Dexie.js documentation, RxDB
- web.dev: Back/forward cache