skip to content

In IndexedDB, what does IDBObjectStore.createIndex() give you that the object store's primary key does not, and what happens to a record whose value has no property at the index's key path?

level: middleimportance: should knowfreq 45%

answer

  1. one store, one primary key — that's the limit
  2. a second sorted view over a chosen field
  3. no property, no entry
  4. the array flag files each element
  5. only creatable during an upgrade

basics

~20 s

An index is a second sorted view of a store, keyed by a chosen property, so records can be looked up and range-scanned by that property rather than only by primary key. Records missing the index key path simply get no index entry and never appear in index queries.

solid answer

~50 s

An object store can only be queried by its primary key, so `createIndex(name, keyPath, options)` builds a second sorted structure mapping an index key to primary keys. You then query through `store.index('byEmail')`, which offers the same `get`, `getAll`, `count`, and `openCursor` surface as the store — results come back ordered by the *index* key, and each cursor exposes both `cursor.key` (index key) and `cursor.primaryKey`. Indexes are **sparse**: a record whose value has nothing at the index key path gets no entry at all, so it is invisible to index queries while remaining perfectly readable by primary key. Two options matter: `unique: true` makes a duplicate index key fail the write with a `ConstraintError`, and `multiEntry: true` makes an array-valued property produce one entry per element, which is how tag-style lookups are built. Like stores, indexes can only be created inside a version upgrade, and creating one back-fills every existing record.

code

javascript · 23 lines
javascript
const request = indexedDB.open('shop', 2);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const orders = event.target.transaction.objectStore('orders');
  if (!orders.indexNames.contains('byCustomer')) {
    orders.createIndex('byCustomer', 'customerId');
  }
};

request.onsuccess = (event) => {
  const db = event.target.result;
  const tx = db.transaction('orders', 'readonly');
  const index = tx.objectStore('orders').index('byCustomer');

  const all = index.getAll('cust-7');       // every order for that customer
  const first = index.getKey('cust-7');     // primary key of the first match

  tx.oncomplete = () => {
    console.log(all.result.length, first.result);
    db.close();
  };
};

go deeper

for a junior

Know that an object store can only be looked up by its primary key, and that createIndex adds a second way in, keyed by a field you choose and queried through store.index(name).

for a middle

Explain that indexes are sparse, that index keys are non-unique unless declared otherwise, what cursor.key and cursor.primaryKey each hold, and what multiEntry does to an array-valued field.

for a senior

Show that you have paid the costs: back-filling an index over a large store inside the upgrade transaction, a unique index aborting an upgrade on dirty legacy data, and screens that under-report because old records predate the indexed field.

for a principal

Own the schema shape — which access paths justify an index against write amplification and storage, and how index additions are sequenced across releases so a client several versions behind still upgrades cleanly.

## The problem an index solves An IndexedDB object store is keyed by exactly one thing: its primary key. `store.get(key)` is fast, and a cursor can walk primary keys in order. Everything else — "find the user with this email", "all orders in September", "posts tagged offline" — has no efficient path. Without an index the only honest implementation is to iterate the entire store and filter in JavaScript, which is linear in the number of records and pulls every value across into the page. A **secondary index** fixes that. `IDBObjectStore.createIndex(name, keyPath, options)` creates a second sorted structure whose keys are extracted from each record at `keyPath` and whose entries point back at primary keys. It is maintained by the browser: every subsequent `add`, `put`, and `delete` updates it inside the same transaction, so the index is never stale relative to a committed read. ```js req.onupgradeneeded = (event) => { const db = event.target.result; const orders = db.createObjectStore('orders', { keyPath: 'id' }); orders.createIndex('byCustomer', 'customerId'); orders.createIndex('byEmail', 'email', { unique: true }); orders.createIndex('byTag', 'tags', { multiEntry: true }); }; ``` ## Querying through an index `store.index('byCustomer')` returns an `IDBIndex`, which mirrors the store's read surface: `get`, `getKey`, `getAll`, `getAllKeys`, `count`, `openCursor`, `openKeyCursor`. The differences are the ones that matter in an interview: - `index.get(k)` returns the **value** of the first matching record; `index.getKey(k)` returns its **primary key** instead. - Results are ordered by the index key, not the primary key. That is the whole point: an index on a timestamp gives you chronological iteration that the primary key never had. - A cursor over an index exposes both keys — `cursor.key` is the index key, `cursor.primaryKey` is the store key, and `cursor.value` is the record. - Index keys need not be unique by default. Several records can share `customerId`, and a cursor visits each of them; `get` only ever returns the first in order. ## Indexes are sparse This is the behaviour people are actually being tested on. If a record's value has no property at the index key path — or has one whose value is not a valid key type — **no index entry is created for it**. The write itself succeeds; the record is simply absent from that index. The practical effect is a class of bug that looks like data loss and is not. A field added later to your record shape means older records have no entry in an index built on it, so a screen driven by `index.getAll()` shows a partial list while `store.getAll()` shows everything. The fix is a data migration that back-fills the property, not a change to the query. It is also, occasionally, a feature: an index on `deletedAt` contains only the records that have one, giving you a cheap "tombstoned records" view. ## unique: true `{ unique: true }` turns the index into a constraint. A write whose index key already exists fails with a `ConstraintError`, and if that request error goes unhandled it aborts the transaction — rolling back everything else the transaction had done. This is the only declarative uniqueness check IndexedDB offers on a non-primary field. The constraint is also enforced at creation time: if the existing records already contain duplicates for that key path, the `createIndex` call fails and the version upgrade aborts, leaving the database on its old version. Adding a unique index to a shipped store is therefore a migration problem, not a one-line schema edit. ## multiEntry: true When the value at the key path is an array, `{ multiEntry: true }` files one index entry per element rather than one entry keyed by the whole array. A record with `tags: ['offline', 'sync']` becomes reachable by `index.getAll('offline')` and by `index.getAll('sync')`. Without the flag, the array is treated as a single compound key, and only the exact array `['offline', 'sync']` matches. `multiEntry` cannot be combined with an array key path (a compound key), since the two ask for contradictory things. ## When indexes are created — and what they cost `createIndex` and `deleteIndex` are only legal on a store obtained from the `versionchange` transaction, which means inside `upgradeneeded`. Creating an index makes the browser read every existing record in the store and build entries for it, inside that transaction — on a large store that is real, blocking work the user waits through on first open after the update. Indexes also cost space and slow writes slightly, since every write maintains every index on that store. Index the fields you actually query, not every field you have. ## What people get wrong Expecting `index.get()` to return all matches rather than the first; assuming a missing field indexes as `undefined` rather than not at all; trying to add an index outside an upgrade; and adding `unique: true` to a store whose existing data already violates it, then being surprised the whole upgrade rolls back.

  • What is the difference between index.get(key) and index.getAll(key) when several records share that index key?
    `get` returns the value of only the first matching record in index order and gives no hint that others exist; `getAll` returns every matching value as an array, and accepts an optional count to cap it. Non-unique index keys are the normal case, so `get` on an index is safe only when you know the field is unique — otherwise you are silently reading one arbitrary record.
  • Why can adding a unique index to a shipped store fail the whole upgrade?
    `createIndex` back-fills entries for every existing record, and if two of them already carry the same index key the call raises a `ConstraintError` inside the `versionchange` transaction. That transaction aborts, so the database stays on its old version and the app reopens into the previous schema. Deduplicate the data in an earlier version step, then add the constraint.
  • How would you query a range through an index rather than a single value?
    Every index read method accepts an `IDBKeyRange` in place of a bare key, so `index.getAll(IDBKeyRange.bound(start, end))` returns all records whose index key falls in that span, and `index.openCursor(range)` walks them in index order. That is the payoff of an index over a timestamp or a score field: ordered range access the primary key could never give you.

saying these in an interview costs you the question

  • Expecting index.get() to return every matching record
  • Thinking a missing field is indexed under undefined
  • Believing index keys must be unique by default
  • Trying to call createIndex outside a version upgrade
  • Assuming an array field is searchable element-wise without multiEntry

context