complex queries without loading the entire object store
Advanced queries in IndexedDB go far beyond getAll(): cursor iteration with IDBKeyRange, compound keys across multiple fields, and multi-entry indexes for array values allow precise, memory efficient queries directly on the browser's storage engine. Anyone who masters these tools no longer needs to filter data client side, letting IndexedDB handle preselection instead.
Table of Contents
- 1. Why getAll() falls short for advanced queries
- 2. Cursor iteration: the base tool for targeted queries
- 3. IDBKeyRange: ranges instead of single values
- 4. Cursor direction and duplicate handling
- 5. Compound keys: querying across multiple fields
- 6. Multi-entry indexes for array values
- 7. Pagination with cursor.advance() and continue()
- 8. Performance analysis with Chrome DevTools
- 9. Query strategies compared head to head
- 10. Summary
- 11. FAQ
1. Why getAll() falls short for advanced queries
Many developers first learn IndexedDB through the simple method getAll(), which loads every entry of an object store into memory at once. For small data volumes that works fine, but once an object store holds tens or hundreds of thousands of entries, getAll() becomes a performance problem: the entire dataset has to be deserialized and held in memory just to be further narrowed down client side with filter() and sort(). Advanced queries in IndexedDB solve exactly this problem by performing the filtering directly at the database level.
The core tool for advanced queries in IndexedDB is the IDBCursor, which walks through records one at a time, in a defined order and with an optional range restriction, without materializing the entire object store. Combined with IDBKeyRange for range queries, compound keys for multi field search, and multi-entry indexes for array fields, a query toolbox emerges that can simulate a full fledged SQL WHERE clause for many use cases, with no SQL engine at all.
Moving from getAll() to real advanced queries in IndexedDB pays off especially in offline first applications, such as calendars with thousands of appointments, product catalogs with filtering features, or log viewers that need to filter by time range and category simultaneously. Anyone who has internalized these patterns no longer relies on client side re-filtering but uses the browser's storage engine, which was built exactly for this task.
2. Cursor iteration: the base tool for targeted queries
An IDBCursor is created through objectStore.openCursor() or index.openCursor() and delivers records not as an array but one after another through repeated onsuccess events. Every call to cursor.continue() moves the cursor to the next matching entry, which means only a single record ever needs to be held in memory at any point in time. For advanced queries in IndexedDB, this streaming behavior is the decisive difference from getAll().
The cursor approach also allows iteration to be stopped early once enough results have been collected, a pattern that is not possible at all with getAll(), since that method always loads the complete dataset. For an application that only needs to show the first 20 hits of a search, early cursor termination saves significant time and memory, especially with object stores holding hundreds of thousands of entries.
// Advanced query with a cursor: stop early once enough matches are found
async function findFirstMatches(db, storeName, predicate, limit) {
return new Promise((resolve, reject) => {
const results = [];
const tx = db.transaction(storeName, "readonly");
const request = tx.objectStore(storeName).openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor || results.length >= limit) {
resolve(results);
return;
}
if (predicate(cursor.value)) {
results.push(cursor.value);
}
cursor.continue(); // move to the next record, one at a time
};
request.onerror = () => reject(request.error);
});
}
const activeOrders = await findFirstMatches(
db,
"orders",
(order) => order.status === "active",
20
);
3. IDBKeyRange: ranges instead of single values
Without IDBKeyRange, every range query in IndexedDB would have to go through the complete dataset with client side filtering. With IDBKeyRange.bound(lower, upper), IDBKeyRange.lowerBound(value) and IDBKeyRange.upperBound(value), advanced queries in IndexedDB run directly on the storage engine, so only the entries that actually match are ever read from the data store. The cursor moves exclusively within the specified range, which brings enormous performance benefits, especially with sorted indexes such as timestamps.
A common pattern for advanced queries in IndexedDB is date range search: an index on a createdAt field combined with IDBKeyRange.bound(startDate, endDate) returns only entries within a time window, without the application having to perform date comparisons for every entry itself. The optional parameters lowerOpen and upperOpen additionally control whether the boundary values themselves are included or excluded, analogous to > versus >= in SQL.
// Advanced range query using IDBKeyRange on an indexed field
async function findOrdersInDateRange(db, startDate, endDate) {
return new Promise((resolve, reject) => {
const results = [];
const tx = db.transaction("orders", "readonly");
const index = tx.objectStore("orders").index("createdAt");
// Range includes startDate, excludes endDate (like >= and <)
const range = IDBKeyRange.bound(startDate, endDate, false, true);
const request = index.openCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
4. Cursor direction and duplicate handling
The second parameter of openCursor(range, direction) controls the iteration direction of advanced queries in IndexedDB. The values "next" and "prev" walk the index in ascending or descending order, while "nextunique" and "prevunique" return each key value only once on non-unique indexes, even if multiple records share the same index value. This fine grained control replaces manual deduplication after loading the data.
A practical example of direction control for advanced queries in IndexedDB: a log application that should show the newest entries first opens the cursor with "prev" on an index sorted by timestamp, instead of loading all entries and reversing them client side. On an index with many duplicates, such as a status field with few possible values, "nextunique" returns exactly one record per status value, ideal for quickly listing all occurring categories.
5. Compound keys: querying across multiple fields
A compound key, also called a composite index, combines multiple fields of an object into a single indexed key. At store creation time this is defined with an array of field names, for example store.createIndex("byCustomerAndDate", ["customerId", "createdAt"]). Advanced queries in IndexedDB use this composite index to filter by both customer and date in a single cursor pass, instead of intersecting two separate queries client side.
The decisive advantage of compound keys in advanced IndexedDB queries lies in the field order: a range query with IDBKeyRange.bound([customerId, startDate], [customerId, endDate]) only works correctly if customerId is the leading field before createdAt in the index, analogous to composite indexes in relational databases, where the field order determines usability for particular query patterns.
// Create a compound index at store creation time (inside onupgradeneeded)
function setupCompoundIndex(db) {
const store = db.createObjectStore("orders", { keyPath: "id" });
store.createIndex("byCustomerAndDate", ["customerId", "createdAt"]);
}
// Advanced query: filter by customer AND date range in a single cursor pass
async function findCustomerOrdersInRange(db, customerId, startDate, endDate) {
return new Promise((resolve, reject) => {
const results = [];
const tx = db.transaction("orders", "readonly");
const index = tx.objectStore("orders").index("byCustomerAndDate");
// customerId must be the leading field for this range to work correctly
const range = IDBKeyRange.bound(
[customerId, startDate],
[customerId, endDate]
);
const request = index.openCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
6. Multi-entry indexes for array values
Objects with array fields, such as orders with multiple tags or products with multiple categories, benefit from the option multiEntry: true when creating an index. Without this option, IndexedDB would treat the entire array as a single key, making queries for a single contained value impossible. With multiEntry: true, IndexedDB instead creates a separate index entry for every array element, each pointing back to the same record.
For advanced queries in IndexedDB, this means: a search for all orders with the tag "urgent" returns every match through a multi-entry index in a single get() or cursor call, regardless of the position of the tag in the array or how many other tags the record otherwise contains. This is the native IndexedDB equivalent of a contains filter on an array column in relational databases.
// Multi-entry index: each array element becomes its own index entry
function setupMultiEntryIndex(db) {
const store = db.createObjectStore("orders", { keyPath: "id" });
store.createIndex("byTag", "tags", { multiEntry: true });
}
// Advanced query: find all orders that contain a specific tag
async function findOrdersByTag(db, tag) {
const tx = db.transaction("orders", "readonly");
const index = tx.objectStore("orders").index("byTag");
return index.getAll(tag); // matches regardless of array position
}
const urgentOrders = await findOrdersByTag(db, "urgent");
7. Pagination with cursor.advance() and continue()
For pagination, the cursor offers two complementary methods: continue(key) moves the cursor to the next entry or optionally directly to a given key, while advance(count) skips a fixed number of entries without materializing them. For advanced queries in IndexedDB with page based navigation, advance() is more efficient than manually counting and skipping in a continue() loop, because the storage engine can optimize the skip internally.
A robust pagination strategy for advanced queries in IndexedDB does not store the page number but the last seen key, and uses IDBKeyRange.lowerBound(lastKey, true) for the next page. This cursor based pagination pattern stays consistent even as data changes between two page requests, whereas pure offset pagination with advance() can duplicate or skip entries under concurrent writes.
// Cursor-based pagination: stable even if records are inserted concurrently
async function getNextPage(db, indexName, lastSeenKey, pageSize) {
return new Promise((resolve, reject) => {
const results = [];
const tx = db.transaction("orders", "readonly");
const index = tx.objectStore("orders").index(indexName);
const range = lastSeenKey
? IDBKeyRange.lowerBound(lastSeenKey, true) // exclude the last seen key
: null;
const request = index.openCursor(range, "next");
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor || results.length >= pageSize) {
resolve({ items: results, lastKey: cursor ? cursor.key : null });
return;
}
results.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
8. Performance analysis with Chrome DevTools
To check whether advanced queries in IndexedDB actually use an index instead of accidentally iterating over the primary key, the Application tab in Chrome DevTools helps, letting you inspect the complete object store including all defined indexes. A common mistake is creating an index but still executing the query through objectStore.openCursor() instead of index.openCursor(), which means the index is never used and the query still runs linearly over all entries.
A simple but effective test for advanced queries in IndexedDB is a timing comparison: a query using getAll() followed by client side filtering, against the same query using a cursor with a matching index and IDBKeyRange, measured with performance.now() before and after each operation. With object stores holding more than 10,000 entries, the difference regularly shows up in the range of several hundred milliseconds in favor of the index based cursor query.
9. Query strategies compared head to head
The following table sets the common patterns for advanced queries in IndexedDB against the naive alternatives many developers reach for first.
| Requirement | Naive approach | Advanced query | Benefit |
|---|---|---|---|
| Finding values in a range | getAll() + filter() |
index.openCursor(IDBKeyRange.bound(...)) |
Only matching entries are ever read |
| Filtering by two fields | Intersecting two queries client side | Compound index with ["a", "b"] |
One cursor pass instead of two queries |
| Searching for an array value | getAll() + Array.includes() |
Index with multiEntry: true |
Direct hit without a full scan |
| Newest entries first | getAll() + Array.reverse() |
openCursor(null, "prev") |
Order already comes sorted from the engine |
| Loading data page by page | Offset pagination with slice() |
Cursor with last key as range start | Stays consistent even under concurrent writes |
The table makes it clear: advanced queries in IndexedDB shift filtering logic from the application into the storage engine, which is optimized for exactly this task. The naive approach with getAll() only works as long as the data volume stays small, while the index based patterns remain consistently performant even as object stores grow.
Mironsoft
JavaScript architecture, offline databases and query performance
IndexedDB queries that stay fast even at scale?
We model compound keys, multi-entry indexes and cursor based pagination for IndexedDB databases that remain performant to query even with hundreds of thousands of entries.
Schema design
Planning index structure and compound keys to match actual query patterns
Query refactoring
Migrating from getAll() filter logic to cursor and IDBKeyRange based queries
Performance audit
Measuring and optimizing existing IndexedDB queries with Chrome DevTools
10. Summary
Advanced queries in IndexedDB turn a simple key value database into a powerful query tool that comes close to complex SQL queries in many cases. Cursor iteration with IDBKeyRange replaces loading entire object stores with targeted, memory efficient traversal of only the relevant entries. Compound keys enable filtering across multiple fields in a single index pass, while multi-entry indexes make array fields like tags or categories efficiently searchable.
Anyone who consistently applies advanced queries in IndexedDB no longer has to rely on client side filtering and sorting, which inevitably becomes a performance bottleneck as data volumes grow. The combination of cursor direction, pagination based on the last seen key, and properly modeled indexes provides a query foundation that stays consistently fast even with object stores holding hundreds of thousands of entries.
Advanced IndexedDB queries — the essentials at a glance
Cursor instead of getAll()
openCursor() streams records one at a time, enables early termination and saves memory.
IDBKeyRange
Range queries with bound(), lowerBound(), upperBound() instead of client side comparison.
Compound keys
Multiple fields as one index, field order determines which range queries are usable.
Multi-entry index
multiEntry: true indexes each array element individually, ideal for tag searches.