IndexedDB

IndexedDB is a full database baked into the browser. Where localStorage gives you a tiny string-to-string map, IndexedDB gives you something closer to a real datastore.

  • It stores almost any kind of value under a key, and keys themselves can be several different types.
  • It runs operations inside transactions, so a group of writes either all lands or none of it does.
  • It can query ranges of keys and search secondary fields through indexes.
  • It holds far more data than localStorage — enough to keep a whole app’s dataset offline.

For a plain client-server app, most of that is overkill. IndexedDB earns its keep in offline-capable apps, usually paired with Service Workers and other background machinery so the app keeps working with no network.

The native API, defined in the specification at https://www.w3.org/TR/IndexedDB, is event-based. You fire off a request, then wait for success or error events on it.

You can also drive it with async/await through a promise-based wrapper such as idb. That reads much nicer, but the wrapper can’t cover every case events handle. So we’ll learn the event API first, build a solid mental model, and switch to the wrapper once the ideas are clear.

Opening a database

Before you can read or write anything, you open (connect to) a database.

let openRequest = indexedDB.open(name, version);
  • name — a string naming the database.
  • version — a positive integer version, 1 by default. We’ll get to why version matters in a moment.

You can keep many databases side by side under different names, but every one of them belongs to the current origin (protocol + domain + port). A database created by one site is invisible to another.

The open call doesn’t return a database. It returns an openRequest object, and you listen for events on it:

  • success — the database is ready. The database object lives in openRequest.result, and that’s what you use from here on.
  • error — opening failed.
  • upgradeneeded — the database is ready but its stored version is behind the version you asked for (more below).
indexedDB.open(“store”, 2) → openRequest
upgradeneeded
stored version < requested; create / migrate stores
success
use openRequest.result as db
error
opening failed
When upgradeneeded runs and finishes cleanly, success fires next.
Opening a database is a request that resolves into one of three event paths.

IndexedDB ships with a schema-versioning mechanism that server databases simply don’t have.

Here’s why it needs one. A server database sits on your machine, under your full control — you migrate it whenever you deploy. An IndexedDB database sits in each visitor’s browser, out of your reach. When you push a new version of your app and a returning visitor loads it, their local database might be stale and need upgrading on the spot.

If the version stored in the browser is lower than the one you pass to open, the browser fires an upgradeneeded event. That’s your chance to inspect the old version and reshape the data structures.

The same upgradeneeded event fires when the database doesn’t exist yet — its version is treated as 0 — so it doubles as your one-time initialization hook.

Say you’ve shipped the first release of your app. Open the database at version 1 and do first-time setup inside upgradeneeded:

let openRequest = indexedDB.open("store", 1);

openRequest.onupgradeneeded = function() {
  // fires when the visitor has no database yet
  // ...perform initialization...
};

openRequest.onerror = function() {
  console.error("Error", openRequest.error);
};

openRequest.onsuccess = function() {
  let db = openRequest.result;
  // carry on working with the database through the db object
};

Later you ship release two. Now open at version 2 and handle the jump:

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = function(event) {
  // the existing database version is below 2 (or it doesn't exist yet)
  let db = openRequest.result;
  switch(event.oldVersion) { // the version already on disk
    case 0:
      // 0 means the visitor had no database at all
      // perform first-time initialization
    case 1:
      // the visitor was on version 1
      // migrate it forward
  }
};

Notice the deliberate lack of break statements. Because the current version is 2, the handler covers version 0 (a brand-new visitor with nothing) and version 1 (someone upgrading). A visitor coming from 0 falls through both cases and runs the full setup chain; a visitor on 1 only runs the case 1 migration.

Only after upgradeneeded completes without throwing does openRequest.onsuccess fire and the database count as open.

To wipe a database entirely:

let deleteRequest = indexedDB.deleteDatabase(name)
// deleteRequest.onsuccess / onerror report the outcome

The parallel-update problem

Versioning brings one sharp edge worth handling up front. Picture this:

  1. A visitor opens your site in a tab, connected to database version 1.
  2. You deploy an update — your code is now newer.
  3. The same visitor opens your site in a second tab.

The first tab still holds an open connection to version 1. The second tab, running the new code, tries to bump the database to version 2 in its upgradeneeded handler.

Tab A (old code)
open connection
DB version 1
shared DB
can’t be v1 and v2
Tab B (new code)
wants to upgrade
DB version 2
One shared database, two tabs, two versions in flight.

The catch: both tabs share one database, because it’s the same origin. It can’t be version 1 and version 2 at once. To reach version 2, every connection to version 1 has to close first — including the one in the first tab.

To coordinate this, the browser fires a versionchange event on the outdated database object. You listen for it, close that stale connection, and probably nudge the visitor to reload so they pick up the new code.

Skip that listener and the story ends badly: the second, newer connection can never open. Instead of success, its openRequest gets a blocked event, and the second tab is stuck.

Here’s the full handling for a clean parallel upgrade. The onversionchange handler fires when this connection has become the outdated one and closes it:

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = ...;
openRequest.onerror = ...;

openRequest.onsuccess = function() {
  let db = openRequest.result;

  db.onversionchange = function() {
    db.close();
    alert("The database is outdated, please reload the page.")
  };

  // ...the db is ready, use it...
};

openRequest.onblocked = function() {
  // shouldn't fire if onversionchange is handled correctly

  // it means another open connection to the same database
  // never closed after its db.onversionchange fired
};

So there are two complementary listeners:

  1. db.onversionchange tells this connection that something elsewhere is trying to upgrade past it — time to step aside.
  2. openRequest.onblocked tells a new connection that some older connection refuses to close, so the upgrade can’t proceed.

You can make db.onversionchange friendlier — prompt the visitor to save unsaved work before closing, for instance. An alternative design flips it around: don’t auto-close in onversionchange; instead let the new tab’s onblocked handler tell the visitor the new version can’t load until the other tabs are closed.

These collisions are rare, but leave your app with at least an onblocked handler so it fails loudly instead of dying in silence.

Object stores

To keep anything in IndexedDB you need an object store.

The object store is the central concept here. Other databases call the equivalent a “table” or a “collection” — it’s the container the data lives in. One database can hold several stores: one for users, one for products, and so on.

Despite the name, stores aren’t limited to objects.

A store can hold almost any value, including deeply nested objects.

IndexedDB clones and stores values with the structured serialization algorithm. Think of it as JSON.stringify with a much wider reach — it handles Date, Blob, typed arrays, Map, Set, and more.

What it can’t store is an object with circular references — those aren’t serializable, and JSON.stringify chokes on them too.

Every value in a store needs a unique key.

A key must be a number, date, string, binary, or array. It’s the value’s identifier — you look up, update, and delete records by key.

database: teaShopobject store: teas (keyPath: id)keyvaluechaiid: chai, price: 5matchaid: matcha, price: 8oolongid: oolong, price: 10keys are unique and kept sortedobject store: ordersa second, separate store
A database holds one or more object stores; inside a store, every unique key points to a stored value.

You can hand IndexedDB a key each time you add a value, the way you pass one to localStorage. But for objects there are two nicer options: point IndexedDB at a property to use as the key, or let it auto-generate keys for you.

First, though, the store itself has to exist.

db.createObjectStore(name[, keyOptions]);

This call is synchronous — no request, no await.

  • name is the store’s name, e.g. "teas".
  • keyOptions is optional and carries one of two properties:
    • keyPath — a path to the object property IndexedDB uses as the key, e.g. id.
    • autoIncrement — if true, each new record gets an automatically generated, ever-increasing number as its key.

Leave keyOptions out and you’ll have to supply an explicit key yourself every time you store something.

This store uses each tea’s id property as its key:

db.createObjectStore('teas', {keyPath: 'id'});

Stores can only be created, removed, or altered while the DB version is changing — inside an upgradeneeded handler.

That’s a hard rule of the API. Outside the handler you can freely add, read, update, and delete records, but the structure — which stores and indexes exist — is frozen except during a version upgrade.

When it’s time to migrate, two strategies work:

  1. Write per-version upgrade steps: 1→2, 2→3, 3→4, and so on. In upgradeneeded, compare the old and new versions and run each intermediate step in turn (old 2, now 4 → run 2→3, then 3→4).
  2. Or just inspect the current shape. db.objectStoreNames is a DOMStringList with a contains(name) method, so you check what exists and create only what’s missing.

For a small database the second approach is often less code:

let openRequest = indexedDB.open("db", 2);

// create / upgrade the database without explicit version checks
openRequest.onupgradeneeded = function() {
  let db = openRequest.result;
  if (!db.objectStoreNames.contains('teas')) { // no "teas" store yet
    db.createObjectStore('teas', {keyPath: 'id'}); // create it
  }
};

To drop a store:

db.deleteObjectStore('teas')

Transactions

“Transaction” is a general database term, and IndexedDB uses it in the usual sense.

A transaction is a bundle of operations that must either all succeed or all fail together.

Take a purchase. You need to:

  1. Deduct the money from the buyer’s account.
  2. Add the item to their inventory.

If step 1 goes through and then the power cuts out before step 2, you’ve charged someone for nothing. Both steps should commit together (purchase done) or both should roll back (at least the buyer keeps their money and can retry). Transactions give you exactly that guarantee.

In IndexedDB, every data operation happens inside a transaction.

Starting one:

db.transaction(store[, type]);
  • store is the store name the transaction will touch, e.g. "teas". Pass an array of names to span multiple stores.
  • type is one of:
    • readonly — reads only. The default.
    • readwrite — reads and writes data, but still can’t create, remove, or alter stores.

There’s a third type, versionchange, that can do everything, including structural changes — but you can’t create it by hand. The browser creates a versionchange transaction automatically when it runs your upgradeneeded handler, which is precisely why that handler is the only place structure can change.

With a transaction open, you get the store from it and add a record:

let transaction = db.transaction("teas", "readwrite"); // (1)

// get the object store to operate on
let teas = transaction.objectStore("teas"); // (2)

let tea = {
  id: 'oolong',
  price: 10,
  created: new Date()
};

let request = teas.add(tea); // (3)

request.onsuccess = function() { // (4)
  console.log("Tea added to the store", request.result);
};

request.onerror = function() {
  console.log("Error", request.error);
};

Four steps, always in this shape:

  1. Open a transaction, naming every store it will access — (1).
  2. Get the store object with transaction.objectStore(name)(2).
  3. Issue the request, here teas.add(tea)(3).
  4. Handle its success / error, then fire more requests if needed — (4).
db
.transaction(“teas”,“readwrite”)
transaction
.objectStore(“teas”)
store
.add(tea)
request
From database to a single write request, and back through the event handlers.

Two methods store a value:

  • put(value, [key]) Adds value to the store. Pass key only when the store has neither keyPath nor autoIncrement. If a record with that key already exists, put overwrites it.

  • add(value, [key]) Same as put, except a colliding key makes the request fail with a "ConstraintError". Use add when a duplicate should be treated as a mistake.

Just like opening a database, the store request (teas.add(tea)) resolves through success / error events.

  • For add, request.result is the key of the new record.
  • Any failure lands in request.error.

How transactions auto-commit

Above we opened a transaction and ran one add. But a transaction can hold many requests that must all commit or all roll back. So how do you tell it “I’m done, no more requests”?

You don’t.

A future 3.0 revision of the spec may add a manual finish, but the 2.0 API in wide use has none.

Once every pending request in a transaction has finished and the microtask queue has drained, the transaction commits on its own.

In practice: a transaction commits when its requests are all done and the current stretch of synchronous code finishes. No explicit call needed — the earlier example didn’t need one.

This auto-commit rule has a consequence that trips people up. You can’t drop an asynchronous operation like fetch or setTimeout into the middle of a transaction. IndexedDB won’t hold the transaction open waiting for it.

Below, request2 on line (*) fails, because by the time fetch resolves the transaction has long since committed and closed:

let request1 = teas.add(tea);

request1.onsuccess = function() {
  fetch('/').then(response => {
    let request2 = teas.add(anotherTea); // (*)
    request2.onerror = function() {
      console.log(request2.error.name); // TransactionInactiveError
    };
  });
};

fetch schedules a macrotask, and transactions close before the browser gets around to macrotasks.

Same tick + microtasks
requests chained here still run — transaction stays active
Next macrotask (fetch, setTimeout)
transaction already committed — new requests throw
Microtasks keep a transaction alive; a macrotask like fetch does not.

The spec authors deliberately made transactions short-lived, chiefly for performance. Remember readwrite transactions lock their stores for writing: if one part of the app opens a readwrite on teas, another part wanting the same store queues up behind it. Long-running transactions turn that into visible stalls.

So what do you do when you need fetch and a write together? Don’t interleave them. Fetch first, prepare the data, and only then open the transaction and run all the database requests back to back.

To know a transaction landed, listen for transaction.oncomplete:

let transaction = db.transaction("teas", "readwrite");

// ...perform operations...

transaction.oncomplete = function() {
  console.log("Transaction is complete");
};

Only complete proves the whole transaction was saved. Individual requests can succeed while the final commit still fails — an I/O error, say.

To roll a transaction back yourself:

transaction.abort();

That undoes every change its requests made and fires transaction.onabort.

Handling errors

Write requests fail sometimes.

Expect it — and not only from bugs in your code. External causes count too, like blowing past the storage quota. Your code has to cope.

A failed request aborts its transaction automatically, throwing away every change in it.

Occasionally you’d rather handle the failure — try a different request, maybe — without losing the changes so far, and let the transaction continue. That’s allowed: call event.preventDefault() in request.onerror to stop the automatic abort.

Below, a tea is added with the same id as an existing one, so add raises a "ConstraintError". We handle it and keep the transaction alive:

let transaction = db.transaction("teas", "readwrite");

let tea = { id: 'oolong', price: 10 };

let request = transaction.objectStore("teas").add(tea);

request.onerror = function(event) {
  // ConstraintError fires when a record with this id already exists
  if (request.error.name == "ConstraintError") {
    console.log("A tea with that id already exists"); // handle it
    event.preventDefault(); // keep the transaction alive
    // maybe retry with a different key
  } else {
    // an error we can't handle
    // let the transaction abort
  }
};

transaction.onabort = function() {
  console.log("Error", transaction.error);
};

Error delegation through bubbling

Do you need onerror / onsuccess on every single request? No. You can delegate.

IndexedDB errors bubble: requesttransactiondatabase.

These are real DOM events, with capturing and bubbling phases, though in practice you work at the bubbling stage.

So a single db.onerror can catch failures from anywhere, handy for logging:

db.onerror = function(event) {
  let request = event.target; // the request that failed

  console.log("Error", request.error);
};

But if a specific error was already dealt with, you don’t want it bubbling up and getting logged as unhandled. Stop it at the source with event.stopPropagation():

request.onerror = function(event) {
  if (request.error.name == "ConstraintError") {
    console.log("A tea with that id already exists"); // handle it
    event.preventDefault(); // don't abort the transaction
    event.stopPropagation(); // don't bubble up — swallow it here
  } else {
    // do nothing
    // the transaction will abort
    // we can deal with it in transaction.onabort
  }
};
request.onerror
↑ bubbles
transaction.onerror
↑ bubbles
db.onerror
event.stopPropagation() halts the climb
An error travels request → transaction → database unless a handler stops it.

Searching

There are two ways to look things up in a store:

  1. By key, or by a range of keys. In the teas store that means searching by tea.id.
  2. By some other field, e.g. tea.price. This needs an extra structure called an index.

By key

Start with searching by key.

The search methods accept an exact key or a range — an IDBKeyRange object that describes which keys count as matches.

You build ranges with these constructors:

  • IDBKeyRange.lowerBound(lower, [open]) — keys ≥ lower (or > lower when open is true).
  • IDBKeyRange.upperBound(upper, [open]) — keys ≤ upper (or < upper when open is true).
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) — keys between lower and upper; each open flag excludes that endpoint.
  • IDBKeyRange.only(key) — a range holding a single key. Rarely used.
lowerBound(10)10, 11, 12, …  (10 included)
lowerBound(10, true)11, 12, …  (10 excluded)
upperBound(10, true)…, 8, 9  (10 excluded)
bound(5, 10, false, true)5, 6, 7, 8, 9  (5 in, 10 out)
How the open flags carve out each range on a sorted key line.

Drag the bounds and flip the open flags below to see exactly which keys a bound(...) range selects. The open flags decide whether each endpoint itself counts as a match:

interactiveIDBKeyRange.bound — which keys match

The methods themselves take a query that’s either an exact key or a range:

  • store.get(query) — the first value matching the key or range.
  • store.getAll([query], [count]) — all matching values, capped at count if given.
  • store.getKey(query) — the first key matching the query, usually a range.
  • store.getAllKeys([query], [count]) — all matching keys, capped at count if given.
  • store.count([query]) — how many keys match.

Since id is the key in the teas store, all of these search by id:

// one tea
teas.get('oolong')

// teas with 'chai' <= id <= 'matcha'
teas.getAll(IDBKeyRange.bound('chai', 'matcha'))

// teas with id < 'matcha'
teas.getAll(IDBKeyRange.upperBound('matcha', true))

// every tea
teas.getAll()

// every key where id > 'oolong'
teas.getAllKeys(IDBKeyRange.lowerBound('oolong', true))

By a field, using an index

To search by a field other than the key, you build an index.

An index is an add-on attached to a store that tracks one field. For each distinct value of that field, it holds the list of primary keys whose records carry that value. The picture below makes it concrete.

objectStore.createIndex(name, keyPath, [options]);
  • name — the index’s name.
  • keyPath — the field the index tracks (the field you want to search).
  • options — an optional object:
    • unique — when true, only one record may hold a given value at keyPath; a duplicate raises an error.
    • multiEntry — matters only when the tracked value is an array. By default the whole array is treated as one key; with multiEntry true, each element becomes its own index key, so a record shows up once per array member.

The teas store is keyed by id. Say we want to search by price. First create the index — and like a store, it has to happen in upgradeneeded:

openRequest.onupgradeneeded = function() {
  // indexes must be created here, in the versionchange transaction
  let teas = db.createObjectStore('teas', {keyPath: 'id'});
  let index = teas.createIndex('price_idx', 'price');
};
  • The index tracks the price field.
  • Prices aren’t unique — several teas can cost the same — so we skip unique.
  • Price isn’t an array, so multiEntry doesn’t apply.

With four teas in stock, here’s what the index actually looks like:

store: teasidpricechai5mint5matcha8oolong10indexindex: price_idxpriceprimary keys5chai, mint8matcha10oolong
The price_idx index maps each price to the list of primary keys of teas at that price.

For each price value, the index keeps the list of primary keys of teas at that price. It maintains itself as records change — you never update it by hand.

Searching the index uses the very same methods as a store, just called on the index object:

let transaction = db.transaction("teas"); // readonly
let teas = transaction.objectStore("teas");
let priceIndex = teas.index("price_idx");

let request = priceIndex.getAll(10);

request.onsuccess = function() {
  if (request.result !== undefined) {
    console.log("Teas", request.result); // every tea with price == 10
  } else {
    console.log("No such teas");
  }
};

Ranges work here too, to pull cheap or pricey teas:

// teas where price <= 5
let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

An index is sorted by the field it tracks (price here), so index searches return results in price order rather than id order.

Deleting records

delete finds records to remove by query, with the same call shape as getAll:

  • delete(query) — remove records matching the query.
// delete the tea with id 'oolong'
teas.delete('oolong');

To delete by a non-key field, find the primary key through the index first, then delete by that key:

// find the key of a tea priced at 5
let request = priceIndex.getKey(5);

request.onsuccess = function() {
  let id = request.result;
  let deleteRequest = teas.delete(id);
};

To empty a store:

teas.clear(); // remove every record

Cursors

getAll and getAllKeys return everything as one array. That’s fine until the store outgrows available memory — then getAll can’t build the array at all.

A cursor is the answer.

A cursor walks a store (or a query’s matches) and yields one key/value pair at a time, so memory stays flat no matter how big the store gets.

Because a store is sorted by key, the cursor moves through it in key order, ascending by default.

// like getAll, but streamed through a cursor:
let request = store.openCursor(query, [direction]);

// for keys only (like getAllKeys): store.openKeyCursor
  • query — a key or range, same as getAll.
  • direction — optional traversal order:
    • "next" — the default; climb from the lowest key upward.
    • "prev" — descend from the highest key downward.
    • "nextunique" / "prevunique" — same directions, but skip duplicate keys. Only meaningful on index cursors, where several records can share a key — you get just the first at each key.

The defining trait of a cursor: request.onsuccess fires once per record, not once total.

let transaction = db.transaction("teas");
let teas = transaction.objectStore("teas");

let request = teas.openCursor();

// runs once for each tea the cursor reaches
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let key = cursor.key; // the tea's key (id field)
    let value = cursor.value; // the tea object
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more teas");
  }
};
onsuccess
key ‘chai’
→ continue()
onsuccess
key ‘matcha’
→ continue()
onsuccess
key ‘oolong’
→ continue()
onsuccess
cursor = null
Each cursor.continue() re-fires onsuccess with the next record, until result is null.

Press cursor.continue() below to feel the rhythm: each click re-fires onsuccess, hands you the next record in key order, and eventually delivers a null cursor once the store is exhausted.

interactiveWalking a store with a cursor

The two methods that advance a cursor:

  • advance(count) — jump forward count records, skipping the ones in between.
  • continue([key]) — step to the next matching record, or to the record at (or just after) key if you pass one.

Either way, onsuccess fires again: result holds the cursor at its new position, or null/undefined once nothing is left.

That example ran a cursor over a store. You can also run one over an index — same memory savings, streaming one record at a time. On an index cursor, cursor.key is the index key (the price), and the record’s own key lives in cursor.primaryKey:

let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));

// runs once per record
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let primaryKey = cursor.primaryKey; // the store key (id field)
    let value = cursor.value; // the store object (tea)
    let key = cursor.key; // the index key (price)
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more teas");
  }
};

The promise wrapper

Hanging onsuccess / onerror on every request gets tedious. Event delegation trims some of it, but async/await is what you really want.

The rest of this chapter uses the thin idb wrapper. It exposes a global idb object with promisified IndexedDB methods.

Instead of event handlers, the code reads top to bottom:

let db = await idb.openDB('store', 1, db => {
  if (db.oldVersion == 0) {
    // first-time initialization
    db.createObjectStore('teas', {keyPath: 'id'});
  }
});

let transaction = db.transaction('teas', 'readwrite');
let teas = transaction.objectStore('teas');

try {
  await teas.add(...);
  await teas.add(...);

  await transaction.complete;

  console.log('teas saved');
} catch(err) {
  console.log('error', err.message);
}

Plain sequential code, plus ordinary try..catch.

Error handling with promises

An error you don’t catch propagates outward to the nearest enclosing try..catch, just like any thrown value.

If nothing catches it, it surfaces as an unhandledrejection event on window:

window.addEventListener('unhandledrejection', event => {
  let request = event.target; // the native IndexedDB request
  let error = event.reason; // the unhandled error, same as request.error
  // ...report the error...
});

The “inactive transaction” trap

The auto-commit rule doesn’t go away under the wrapper. A transaction still commits the moment the browser finishes the current code and its microtasks. Slip a macrotask like fetch into the middle and the transaction won’t wait — it commits, and the next request against it fails.

Here it is with async/await:

let transaction = db.transaction("inventory", "readwrite");
let inventory = transaction.objectStore("inventory");

await inventory.add({ id: 'oolong', price: 10, created: new Date() });

await fetch(...); // (*)

await inventory.add({ id: 'oolong', price: 10, created: new Date() }); // Error

The inventory.add after fetch at (*) throws an “inactive transaction” error — the transaction closed while fetch was in flight.

The fix is the one from the native API: don’t mix fetch into a transaction. Split the work in two.

  1. Fetch and prepare everything you need first.
  2. Then open the transaction and write.

Reaching the native objects

Under the hood the wrapper issues a native request, attaches onerror / onsuccess, and hands back a promise that resolves or rejects with the result. That’s enough almost always.

In the rare case you need the underlying request, it hangs off the promise as promise.request:

let promise = teas.add(tea); // grab the promise, don't await yet

let request = promise.request; // native request object
let transaction = request.transaction; // native transaction object

// ...do some low-level IndexedDB work...

let result = await promise; // await the result if you still need it

Summary

Think of IndexedDB as localStorage with real teeth — a key-value database strong enough for offline apps, still approachable day to day.

The authoritative reference is the specification. The current release is 2.0, and a few methods from the 3.0 draft (not a big departure) already have partial support.

The everyday workflow boils down to:

  1. Reach for a promise wrapper such as idb.
  2. Open a database: idb.openDB(name, version, onupgradeneeded).
    • Create stores and indexes in onupgradeneeded, and run version migrations there too.
  3. For every operation:
    • Open a transaction — db.transaction('teas'), readwrite when you’re writing.
    • Grab the store — transaction.objectStore('teas').
  4. Search:
    • By key, call the search methods on the store directly.
    • By another field, create an index and search that.
  5. When the data won’t fit in memory, walk it with a cursor.

Here’s a small store app. The sandboxed preview can’t reach real IndexedDB, so it mimics an object store keyed by id in memory — but the add / put / delete flow, the duplicate-key ConstraintError, and the always-sorted-by-key listing all mirror the real API:

interactiveA teas object store: add, put, delete