Lesson 0004 · ~14 minutes
One skill: look up records by something other than the primary key — and know when a cursor beats dumping the whole store.
In a design review you can say: “secondary lookup needs an
index at upgrade time; ranges use IDBKeyRange;
huge sets stream with cursors — getAll is for modest
results.”
Fine for now. This lesson’s primary source is still MDN — Using IndexedDB (indexes + cursors sections). Skim the transaction lifetime parts while you’re there — they backfill lesson 3.
Lesson 2–3: every record has one primary key. Product queries usually
need more — “all notes for projectId,” “emails by
domain,” “events in this day.” That is what an
index
is for: a secondary ordered lookup path on a property (or path) of the
stored value.
Indexes are
schema. You create them only in
upgradeneeded — same deploy cost as new object stores.
There is no free “add a WHERE in production without a version bump.”
// inside onupgradeneeded only
const store = db.createObjectStore("notes", { keyPath: "id" });
store.createIndex("by_project", "projectId", { unique: false });
store.createIndex("by_email", "email", { unique: true });
unique: true — at most one record per index key (e.g.
email). Violations fail the write.
unique: false (default) — many records share a key (e.g.
project id).
multiEntry: true — when the keyPath is an array of tags,
index each element (advanced; know it exists).
In a normal transaction you open the store, then the index, then read:
const tx = db.transaction("notes", "readonly");
const index = tx.objectStore("notes").index("by_project");
index.get(projectId); // one match (first if non-unique)
index.getAll(projectId); // all with that index key
index.getKey(projectId); // primary key only
index.count(projectId); // how many
Same request lifetime rules as lesson 3: keep work inside the
transaction’s active turns; “done” for multi-step work is still
tx.oncomplete.
IDBKeyRange
expresses ordered ranges of keys (primary or index keys):
| Factory | Meaning |
|---|---|
IDBKeyRange.only(x) |
Exactly x |
IDBKeyRange.lowerBound(x, open?) |
≥ or > x |
IDBKeyRange.upperBound(x, open?) |
≤ or < x |
IDBKeyRange.bound(lo, hi, …) |
Between lo and hi |
const range = IDBKeyRange.bound("2026-07-01", "2026-07-31");
index.getAll(range);
// or store.openCursor(range) on the primary key
Keys must be comparable IndexedDB key types (numbers, strings, dates, …). Design stable, ordered key shapes early — random objects are not valid keys.
Small or bounded results. Simple code. Memory cost is “all matching values at once.”
Stream one record at a time; stop early; process large sets without loading everything into RAM.
Keys only (no values). Useful for existence, paging keys, or cheap scans.
const req = index.openCursor(IDBKeyRange.only(projectId));
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return; // done
// cursor.key = index key; cursor.primaryKey; cursor.value
renderRow(cursor.value);
cursor.continue(); // next match
};
Prefer get/getAll with a
tight range or equality for UI lists you know are
modest. Reach for cursors when the set can be huge, you need early
exit, or you only need keys. “Load the entire object store into
memory every paint” is a design smell — with or without indexes.
put/add
updates indexes. More indexes → more write work.
["projectId", "updatedAt"])
help common ordered queries — plan them up front.
Full cursor update-in-place patterns, complex multiEntry tag systems, or multi-tab live fan-out. Next layers: quotas/eviction and integration deal-breakers.
equal-length choices. Immediate feedback.
When can you add a by_project index on an existing
notes store?
Notes use primary key id and index
by_project on projectId. Load all notes for
one project?
An analytics store may hold hundreds of thousands of events. You need to process matches for one day without holding all of them in memory. Best default tool?
How do you express “keys from A to Z inclusive” for an index query?
Which tradeoff belongs in a design review about adding five new indexes?
store.index(name) + get/getAll/count or
cursors.
IDBKeyRange for ordered ranges; stable key types matter.
getAll for modest sets; cursors for
large/streamed work.
MDN — Using IndexedDB — sections on creating indexes, using indexes, and cursors. This is also the backfill for lesson 3 if you skipped it. Optional: web.dev — Work with IndexedDB.