An IndexedDB store holds 200,000 orders with an index on their timestamp, and you need the 50 most recent. How do cursors and IDBKeyRange let you read that without pulling the whole store into memory?
answer
- one record at a time, in key order
- the sort order decides which end is cheap
- stop by staying silent
- bound, lowerBound, upperBound, only
- count the range without reading it
basics
~20 sOpen a cursor on the timestamp index with direction 'prev' and stop calling continue() after 50 records. A cursor walks index order one record at a time, and an IDBKeyRange bounds which keys it visits, so memory stays proportional to what you keep, not to the store.
solid answer
~50 s`index.openCursor(query, direction)` returns a cursor that visits matching records one at a time in index-key order. Each `success` event hands you a cursor with `key`, `primaryKey`, and `value`; you call `cursor.continue()` to advance, and simply *not* calling it ends the walk. For newest-first, pass `'prev'` to iterate descending, collect 50 values, and stop — the browser never materialises the other 199,950. The `query` argument is a key or an `IDBKeyRange`: `IDBKeyRange.bound(from, to)`, `lowerBound`, `upperBound`, or `only`, each with optional open/closed endpoints, which is what turns "orders in September" into a bounded scan instead of a full one. `getAll(query, count)` is the simpler alternative and often faster because it avoids one event per record, but it only returns ascending order and it builds the whole array in memory. Use `openKeyCursor` when you need keys but not values, and `cursor.advance(n)` to skip.
code
javascript · 23 linesfunction newest(db, storeName, indexName, limit) {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const index = tx.objectStore(storeName).index(indexName);
const out = [];
index.openCursor(null, 'prev').onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor || out.length >= limit) return; // no continue() = done
out.push(cursor.value);
cursor.continue();
};
tx.oncomplete = () => resolve(out);
tx.onerror = () => reject(tx.error);
});
}
function countInRange(db, storeName, indexName, from, to) {
const tx = db.transaction(storeName, 'readonly');
const range = IDBKeyRange.bound(from, to, false, true);
return tx.objectStore(storeName).index(indexName).count(range);
}go deeper
Know that a cursor walks records one at a time in key order, that you advance it with continue(), and that IDBKeyRange describes which span of keys to visit.
Explain why direction 'prev' makes newest-first cheap, why getAll materialises everything and offers no direction, what openKeyCursor saves, and how the four IDBKeyRange constructors differ.
Show the operational reasoning: bounding every read so memory does not track store size, keyset pagination instead of advance() on deep pages, count(range) instead of fetching to measure, and cursor.update/delete for in-place bulk edits.
Own the access-path design — which indexes make the app's hot queries a bounded scan, where read shapes belong relative to the transaction rules, and when the answer is a different data layout rather than a cleverer cursor.
## Two ways to read a range IndexedDB gives you two shapes for reading more than one record, and the interview question is really about knowing when each is right. **`getAll(query, count)`** returns an array of every matching value in one request. It is concise, and because it costs one request rather than one event per record, it is usually the faster of the two for modest result sets. Its costs are that the entire array is built in memory before your callback runs, and that results always come back in ascending key order — there is no direction argument. **`openCursor(query, direction)`** returns an iterator. Its `success` event fires once per matching record with a cursor object; you take what you need and call `continue()` to get the next one. Memory stays proportional to what you retain, you can stop at any point, you can choose descending order, and in a `readwrite` transaction you can mutate the record you are standing on. ## The newest-fifty problem ```js const tx = db.transaction('orders', 'readonly'); const index = tx.objectStore('orders').index('byCreatedAt'); const recent = []; index.openCursor(null, 'prev').onsuccess = (event) => { const cursor = event.target.result; if (!cursor || recent.length === 50) return; // stop: no continue() recent.push(cursor.value); cursor.continue(); }; tx.oncomplete = () => render(recent); ``` The `'prev'` direction is what makes this cheap. Because the index is sorted by timestamp, descending iteration starts at the newest record, so the fifty you want are the first fifty visited. `getAll` cannot express this: it would return the *oldest* fifty, and getting the newest that way means reading all 200,000 and slicing — which is exactly the memory blow-up you were asked to avoid. Ending the walk is done by omission. The cursor advances only when you call `continue()`; return without calling it and no further `success` events fire, the transaction has nothing pending, and it commits. ## IDBKeyRange A key range describes a contiguous span of the sorted key space, and every read method that accepts a key also accepts one — `get`, `getAll`, `getAllKeys`, `count`, `openCursor`, `openKeyCursor`, `delete`. - `IDBKeyRange.only(k)` — exactly one key. - `IDBKeyRange.lowerBound(k, open?)` — from `k` upward; pass `true` to exclude `k` itself. - `IDBKeyRange.upperBound(k, open?)` — up to `k`. - `IDBKeyRange.bound(lower, upper, lowerOpen?, upperOpen?)` — both ends, each independently inclusive or exclusive. ```js const september = IDBKeyRange.bound( new Date('2026-09-01'), new Date('2026-10-01'), false, true ); index.count(september).onsuccess = (e) => console.log(e.target.result); ``` The half-open form above — inclusive lower, exclusive upper — is the one to reach for on time ranges, because it makes consecutive months tile without overlapping. Note that `count()` with a range answers "how many" without transferring a single value, which is far cheaper than fetching to measure length. ## The cursor object On an index cursor, `cursor.key` is the *index* key, `cursor.primaryKey` is the store's primary key, and `cursor.value` is the record. `openKeyCursor` gives you a cursor with the two keys but no value, which avoids deserialising records you are only going to count or filter on key. Directions are `'next'`, `'prev'`, `'nextunique'`, and `'prevunique'`; the `unique` variants visit only the first record for each distinct index key, which is a cheap way to enumerate distinct values of an indexed field. `cursor.advance(n)` skips `n` positions in one step, and `cursor.continue(key)` jumps to a specific key. Naive pagination built on `advance(pageIndex * pageSize)` still walks the skipped entries, so deep pages get progressively slower; remembering the last seen key and starting the next page from `IDBKeyRange.lowerBound(lastKey, true)` is the version that stays flat. In a `readwrite` transaction, `cursor.update(newValue)` rewrites the record at the current position and `cursor.delete()` removes it — the standard way to run a bulk edit without loading everything first. ## Choosing between them Reach for `getAll(range, count)` when the result set is bounded and small and you want it ascending — it is less code and fewer event round trips. Reach for a cursor when the result set is large or unbounded, when you need descending order, when you want to stop early on a condition, or when you are editing as you scan. And remember the transaction rule that governs both: the cursor's `success` handler runs inside the transaction, so issuing the next request from there is legal, but leaving the walk to resume after a network call is not. ## What people get wrong Using `getAll()` with no range or count on a large store; expecting `getAll` to honour a direction; forgetting that not calling `continue()` is how you stop; confusing `cursor.key` with `cursor.primaryKey` on an index cursor; and paginating with `advance()` on deep pages instead of resuming from the last key.
- When is getAll() the better choice than a cursor?When the result set is small and bounded and ascending order is fine. `getAll(range, count)` is one request instead of one event per record, so it typically wins on latency for a page of results, and the code is much shorter. Switch to a cursor once the set is large or unbounded, once you need descending order, or once you want to stop on a condition rather than a count.
- Why does paginating with cursor.advance(pageIndex * pageSize) get slower on later pages?`advance` skips positions by walking them, so page 500 still traverses the 499 pages before it. Keyset pagination avoids that: remember the last key you emitted and start the next page at `IDBKeyRange.lowerBound(lastKey, true)`, so every page costs the same regardless of depth. It also stays correct when records are inserted between page loads.
- On a cursor opened over an index, what is the difference between cursor.key and cursor.primaryKey?`cursor.key` is the index key — the value at the index's key path, which is what determines iteration order and can repeat across records. `cursor.primaryKey` is the record's key in the object store, which is unique and is what you would pass to `store.get` or `store.delete`. On a cursor opened directly over the store, the two are the same value.
- How do you delete every record in a date range without loading them?Two ways. `store.delete(IDBKeyRange.bound(from, to))` removes a primary-key range in one request. If the range is expressed over an *index*, delete by walking `index.openKeyCursor(range)` in a readwrite transaction and calling `cursor.delete()` at each position — `openKeyCursor` avoids deserialising values you are only going to discard.
saying these in an interview costs you the question
- Calling getAll() with no range or count on a huge store
- Expecting getAll to return results in descending order
- Not knowing that omitting continue() is how a cursor stops
- Confusing the index key with the primary key on an index cursor
- Paginating deep pages with advance() instead of resuming from the last key