Creating Compound Indexes for Multi-Field Filtering
Developers implementing offline-first state persistence frequently encounter O(n) full-table scans or DataError exceptions when chaining independent IDBIndex lookups. This degrades PWA responsiveness and violates strict latency SLAs for mobile web teams. This walkthrough is part of Indexing Strategies for Fast Queries and shows how a single compound index resolves a two-field predicate in one B-tree traversal.
Root Cause Analysis
IndexedDB’s default indexing model optimizes only single-key lookups. Without explicit schema-level compound index registration, the browser engine cannot leverage B-tree traversal for intersecting conditions. Attempting to intersect two separate indexes forces sequential cursor iteration or throws DataError when invalid key ranges are passed. Implementing robust Indexing Strategies for Fast Queries requires defining a composite keyPath during the onupgradeneeded lifecycle to enable native multi-field range resolution.
The key insight is that a compound index stores its entries sorted lexicographically across the whole tuple — first by the leading field, then by the next, and so on. That ordering is exactly what lets a single IDBKeyRange carve out “this status, priority from 3 up” as one contiguous slice of the tree. The spec guarantees this lexicographic key comparison, which is why field order in the keyPath is a correctness concern and not a stylistic one.
Step-by-Step Implementation
Step 1: Register Compound Index During Version Upgrade
Define the keyPath as an ordered array of field names in createIndex(). The array order dictates query precedence: leading fields support exact matches, while trailing fields support range queries. All schema operations must use the implicit versionchange transaction — do not call db.transaction() inside onupgradeneeded.
// Must execute inside onupgradeneeded using the implicit versionchange transaction
const request = indexedDB.open('tasks-db', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('tasks', { keyPath: 'id' });
// Compound index: [status, priority]
// unique: false allows multiple records with identical status/priority combos
store.createIndex('status_priority', ['status', 'priority'], {
unique: false,
});
};
Step 2: Construct Async Query Wrapper with Explicit Error Handling
Wrap transaction and index access in a try/catch block. Handle QuotaExceededError and InvalidStateError explicitly. Use IDBKeyRange.bound() to define precise multi-field boundaries.
async function queryCompoundIndex(db, status, minPriority) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readonly');
const store = tx.objectStore('tasks');
// Verify index exists to prevent NotFoundError
if (!store.indexNames.contains('status_priority')) {
console.warn(
'Compound index missing. Falling back to client-side filter.'
);
const fallbackReq = store.getAll();
fallbackReq.onsuccess = () =>
resolve(
fallbackReq.result.filter(
(r) => r.status === status && r.priority >= minPriority
)
);
fallbackReq.onerror = () => reject(fallbackReq.error);
return;
}
const index = store.index('status_priority');
// Bound range: [exact_status, min_priority] to [exact_status, Infinity]
const range = IDBKeyRange.bound([status, minPriority], [status, Infinity]);
const request = index.openCursor(range);
const results = [];
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
Step 3: Execute Filtered Cursor Iteration
Pass exact match values for leading index fields and range bounds for trailing fields. Parameter alignment with the createIndex() array order is mandatory to prevent DataError.
// Executes native B-tree traversal — no client-side filtering required.
const filteredTasks = await queryCompoundIndex(db, 'active', 3);
console.log(`Retrieved ${filteredTasks.length} matching records`);
Verification & Production Safeguards
Before deploying to production, validate query performance and memory safety:
- Latency Profiling: Wrap execution in
performance.now()to measure start/end deltas. Target sub-50 ms on datasets exceeding 10k records with a well-selected compound index. - Range Isolation Verification: Confirm
IDBKeyRangecorrectly isolates target records. Inspectcursor.valuepayloads to ensure no post-fetch.filter()calls are masking inefficient queries. - Memory & Callback Safety: Verify the
onsuccesscallback terminates cleanly whencursorevaluates tonull. Persistent references to large result arrays cause memory leaks in long-lived PWA sessions; clearresultsarrays immediately after DOM rendering or state hydration.
You can also verify in DevTools: open the Application panel, expand IndexedDB, select the store, and confirm status_priority appears under the store’s index list. Run the query twice and compare performance.now() deltas — a correctly bound compound query stays flat as the store grows, while a getAll() fallback scales linearly with record count.
Edge Cases & Fallback Approaches
A few situations break the happy path. The table below maps each to its cause and the fix.
| Edge case | Why it happens | Fallback |
|---|---|---|
| Query needs a range on the leading field | Only the leftmost prefix can range-scan; you cannot range the first field and equality-match the second | Add a second index with the fields reversed, or filter the smaller field client-side |
A record is missing status or priority |
Records missing any key-path field are excluded from the compound index entirely | Write a sentinel (e.g. priority: 0) so the record stays indexable |
| Index does not yet exist on an older client | A pending version upgrade has not run | The indexNames.contains() guard in Step 2 falls back to getAll() plus a client-side filter |
DataError on openCursor |
The key range tuple length or types mismatch the index keyPath |
Validate that the bound arrays match the createIndex field order and primitive types |
The most common fallback is the missing-index guard already shown in Step 2: when store.indexNames.contains('status_priority') is false, the code degrades to a full getAll() and an in-memory filter so the feature still works while the upgrade propagates. This same defensive pattern is essential for the streaming reads described in Query Optimization & Cursors, and for the page-by-page traversal in Paginating Large IndexedDB Result Sets with Cursors.
Note: Compound indexes are immutable post-creation. Schema changes require a version bump and data migration. Always test index creation in a private browsing window to bypass stale schema caches.
Frequently Asked Questions
Can I range-query the first field of a compound index?
Not while equality-matching a later field in the same query. A compound index only supports a range on the last field you constrain, after exact matches on every field before it. If you need to range the leading field, create a separate index with that field first, as covered in Indexing Strategies for Fast Queries.
Why do I get a DataError when opening the cursor?
DataError almost always means the key range you passed does not match the index keyPath — wrong tuple length, or a type mismatch (a string where the index expects a number). Confirm your IDBKeyRange.bound arrays line up with the exact field order and primitive types used in createIndex.
How do I change a compound index after launch?
You cannot mutate an existing index. Increment the database version and recreate it inside onupgradeneeded, migrating data if the key path changed. The safe version-bump procedure is detailed in Database Schema Migrations.
Related
- Indexing Strategies for Fast Queries — the broader guide on index design and field ordering.
- Query Optimization & Cursors — streaming compound-index results without exhausting memory.
- Paginating Large IndexedDB Result Sets with Cursors — keyset pagination over a compound key range.
- Database Schema Migrations — recreating indexes across version upgrades.