Advanced IndexedDB Queries: Cursors, Indexes and Compound Keys Done Right
AI generated
JS
() =>
JavaScript · IndexedDB · Offline Databases · Query Performance
Advanced IndexedDB queries: cursors, indexes and compound keys done right
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.

19 min read IDBCursor · IDBKeyRange · Compound Index · Multi-Entry Chrome · Firefox · Safari

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.

11. FAQ: Advanced IndexedDB Queries

1Why doesn't getAll() suffice?
Always loads the entire object store regardless of the actual amount needed, becomes a problem with large data.
2What is an IDBCursor?
Walks through records one at a time and in order, without loading the full dataset first.
3What is IDBKeyRange for?
Defines a value range for cursor iteration, prevents reading irrelevant entries.
4What is a compound key?
An index of multiple fields, allows filtering across multiple fields in one pass.
5Why does field order matter?
Only the leading field is usable for pure range queries, must match the query pattern.
6What does multiEntry do?
Indexes each array element individually, direct search for single array values possible.
7How does cursor pagination work?
Last seen key is stored, next page starts with lowerBound on that key.
8continue() vs. advance()?
continue() moves to the next entry or key, advance() skips a fixed count.
9How do I check index usage?
Chrome DevTools Application tab shows indexes, timing comparison shows the practical difference.
10What do nextunique/prevunique do?
Return each key value only once, even on non-unique indexes, without manual deduplication.