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.
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.
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.
// Browser Context: Must execute inside onupgradeneeded transaction
// Exception Handling: InvalidStateError if called outside upgrade transaction
db.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(status, minPriority) {
if (!db) throw new Error('IndexedDB instance not initialized');
try {
// Fallback path: If transaction fails due to storage pressure, return empty array
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.');
return await getAllRecordsFallback();
}
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);
return await new Promise((resolve, reject) => {
const results = [];
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = (e) => reject(e.target.error);
});
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.error('Storage quota exceeded during read transaction.');
// Fallback: Trigger cache eviction or prompt user
return [];
}
console.error('Compound index query failed:', err.name, err.message);
throw err;
}
}
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. Zero client-side filtering required.
const filteredTasks = await queryCompoundIndex('active', 3);
console.log(`Retrieved ${filteredTasks.length} matching records`);
Validation & Production Safeguards
Before deploying to production, validate query performance and memory safety:
- Latency Profiling: Wrap execution in
performance.now()to measure start/end deltas. Cross-reference metrics against IndexedDB Architecture & Advanced Patterns standards to guarantee sub-50ms latency on datasets exceeding 10k records. - 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.
Note: Compound indexes are immutable post-creation. Schema changes require a version bump and data migration. Always test index creation in incognito mode to bypass stale schema caches.