Indexing Strategies for Fast Queries

Offline-first state persistence relies on predictable, low-latency data access. For frontend engineers and PWA developers operating on constrained mobile hardware, poorly structured storage schemas quickly degrade into main-thread blocking, UI jank, and failed sync operations. Strategic index design shifts query complexity from O(n) linear scans to O(log n) logarithmic lookups, ensuring consistent performance even when network connectivity is degraded or entirely absent. This guide sits under IndexedDB Architecture & Advanced Patterns and focuses specifically on the schema-level decisions that make queries fast.

This guide covers architectural patterns, API-level implementation details, and production-ready techniques for designing high-performance IndexedDB indexes that minimize query latency and maximize storage efficiency.

How a secondary index resolves a query A diagram showing a query hitting a secondary index B-tree, which maps an indexed value to a primary key, which then fetches the full record from the object store. Query predicate status = 'active' Secondary index B-tree on status Primary key keyPath: id Object store full record

1. Core Indexing Architecture in Browser Storage

Before implementing custom indexes, engineering teams must understand how the underlying IndexedDB Architecture & Advanced Patterns govern B-tree traversal, storage allocation, and cursor mechanics. IndexedDB stores data in a structured key-value format, but its query engine relies heavily on auxiliary B-trees to resolve lookups without scanning entire object stores. Proper index design dictates how the browser engine caches pages, resolves collisions, and manages disk I/O.

1.1 Primary Keys vs. Secondary Indexes

Primary keys enforce uniqueness and dictate the physical ordering of records on disk. They are mandatory and automatically indexed. Secondary indexes act as auxiliary lookup tables that map non-key properties back to primary keys. When designing schemas, avoid indexing properties that are already covered by the primary key or that are rarely queried. Choosing the correct index type prevents redundant storage overhead and optimizes cache locality during cursor iteration.

A secondary index is itself a B-tree keyed on the indexed property, where each leaf points back at the primary key of the matching record. Resolving a query therefore costs two logarithmic traversals: one through the index to find candidate primary keys, and one through the object store’s primary-key B-tree to materialize each record. This indirection is why over-indexing hurts writes — every insert or update must keep every affected index B-tree consistent inside the same transaction.

1.2 Multi-Entry and Unique Constraints

Array-backed properties require multiEntry: true to index individual elements rather than the entire serialized array. This is critical for tag-based filtering or category lookups. Unique constraints (unique: true) prevent duplicate values but introduce synchronous validation overhead during write transactions. In high-throughput offline sync scenarios, prefer application-level deduplication over database-level unique constraints to reduce transaction lock contention.

With multiEntry: true, a record whose tags property is ['urgent', 'billing'] produces two separate index entries, so a lookup on either tag finds the record in O(log n) time. Without it, the array is stored as a single composite key and tag-level filtering collapses back to a full scan. Note that multiEntry is incompatible with compound (array) key paths — you can index an array of scalars by element, or a fixed tuple of fields, but not both at once.

2. Compound Index Design for Multi-Field Filtering

Production PWAs rarely filter on a single property. When queries span multiple attributes, Creating Compound Indexes for Multi-Field Filtering becomes mandatory. The sequence of fields in a compound key dictates selectivity; high-cardinality fields should precede low-cardinality ones to maximize pruning efficiency and minimize cursor traversal depth.

2.1 Field Ordering and Query Selectivity

IndexedDB only utilizes the leftmost prefix of a compound index. Queries omitting the first field will trigger full store scans, negating any indexing benefits. Align index order with your most frequent query predicates. For example, an index on ['status', 'priority', 'createdAt'] efficiently resolves status === 'active' queries, but will not accelerate a query filtering only on priority > 5 without a full scan.

The ordering rule is the single most common source of “my index does nothing” confusion. A compound key is compared lexicographically, field by field, so the index is only useful for an equality match on every field up to (and including) the last one you want to range-scan. An index on ['status', 'priority', 'createdAt'] accelerates status = 'active' AND priority >= 3, but a query on createdAt alone gets no help. If two distinct query shapes both need to be fast, you generally need two indexes.

2.2 Handling Sparse and Nullable Fields

Missing properties default to undefined in IndexedDB, which sorts before all other values in B-tree traversal. Explicitly normalize sparse data or use sentinel values (e.g., null, -1, or 0) to prevent index fragmentation and unpredictable cursor behavior. Consistent typing across records ensures deterministic range boundaries and prevents silent query mismatches.

There is a sharper edge here: a record that lacks the indexed property entirely is not indexed at all by a single-field index, so it simply will not appear in any index-driven query. For compound indexes, if any field in the key path is missing, the whole record drops out of that index. When you need every record to be reachable through an index, write an explicit sentinel value rather than leaving the property undefined.

3. Bounded Queries and Key Range Optimization

Raw equality lookups are only the baseline. Real-world applications require temporal, alphabetical, or numeric range scans. By leveraging precise boundary definitions with IDBKeyRange, developers can drastically reduce memory pressure and main-thread blocking.

3.1 Open vs. Closed Interval Boundaries

Use IDBKeyRange.bound() with explicit lowerOpen and upperOpen flags to exclude boundary values when implementing pagination or time-windowed queries. Closed intervals (false, false) include exact matches, which can cause duplicate record retrieval during cursor continuation if not carefully managed. Always validate boundary types against the index schema to prevent DataError exceptions.

// Time-windowed query on an index keyed by ['channel', 'createdAt'].
// Half-open upper bound (upperOpen = true) excludes the exact end timestamp,
// so adjacent pages never return the same boundary record twice.
function windowRange(channel: string, from: number, to: number): IDBKeyRange {
  return IDBKeyRange.bound(
    [channel, from],   // lower bound, inclusive
    [channel, to],     // upper bound
    false,             // lowerOpen: include `from`
    true,              // upperOpen: exclude `to`
  );
}

3.2 Directional Cursor Traversal

Setting direction: 'prev' or 'next' on cursors allows reverse iteration without loading datasets into memory. Combine with cursor.continue() for efficient streaming. Avoid getAll() on large ranges; it materializes the entire result set in the V8 heap, triggering garbage collection pauses and potential QuotaExceededError on low-memory devices. The cursor mechanics, batching, and keyset pagination that build on these ranges are covered in depth in Query Optimization & Cursors, and the page-by-page recipe lives in Paginating Large IndexedDB Result Sets with Cursors.

4. Index Operations Within Transactional Boundaries

Index reads and writes are bound by strict concurrency rules. Misaligned scopes cause blocking, deadlocks, or stale reads. Properly isolating index operations within IndexedDB Transaction Management guarantees ACID compliance while maintaining UI responsiveness during heavy offline syncs.

4.1 Readonly vs. Readwrite Scope Isolation

Always prefer readonly transactions for index queries. readwrite locks the entire object store, preventing concurrent reads and degrading perceived performance. Modern browsers optimize readonly transactions by allowing parallel execution, significantly accelerating cursor traversal. Acquiring a needlessly wide readwrite scope is also a leading cause of contention; for the failure modes that follow, see Handling Deadlocks in IndexedDB Read/Write Transactions.

4.2 Batch Index Population Strategies

During initial sync or bulk imports, insert records in chunks of 500–1000 to avoid transaction timeouts and memory spikes. Yield to the event loop between batches using setTimeout(..., 0) or requestIdleCallback() to keep the main thread responsive. Use oncomplete on each batch transaction to confirm all index updates are committed before proceeding.

5. Schema Evolution and Index Lifecycle Management

Application requirements evolve, necessitating index additions, modifications, or removals. Handling version bumps requires deterministic upgrade logic. Consult Database Schema Migrations for robust patterns on safely restructuring indexes during onupgradeneeded without data corruption.

5.1 Backward-Compatible Index Additions

New indexes can be added without migrating existing records. IndexedDB automatically populates them during the upgrade transaction, but large stores may experience temporary latency. Schedule index creation during app idle periods or background sync windows to avoid blocking critical user flows.

5.2 Deprecating and Dropping Legacy Indexes

Remove unused indexes immediately after confirming they are no longer queried. Stale indexes consume storage quotas, increase write amplification, and degrade overall throughput. Use store.deleteIndex('name') inside onupgradeneeded and verify removal via store.indexNames before committing the version bump. The end-to-end version-bump procedure is walked through in Step-by-Step IndexedDB Version Upgrade Migration.

Production-Ready Implementation Patterns

TypeScript Compound Index Initialization

interface TaskRecord {
  id: string;
  status: 'pending' | 'active' | 'completed';
  priority: number;
  createdAt: number;
}

async function initializeStore(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('offline-tasks', 2);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;
      if (event.oldVersion < 2) {
        const store = db.createObjectStore('tasks', { keyPath: 'id' });
        store.createIndex(
          'status_priority_created',
          ['status', 'priority', 'createdAt'],
          { unique: false, multiEntry: false }
        );
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function initDatabaseWithFallback() {
  try {
    return await initializeStore();
  } catch (err) {
    if (err instanceof DOMException && err.name === 'QuotaExceededError') {
      console.warn('Storage quota exceeded. Clearing legacy data...');
      // Implement fallback: clear old stores, reduce retention window, or prompt user
    }
    throw new Error('IndexedDB initialization failed', { cause: err });
  }
}

Async Iterator for Key Range Pagination

async function fetchTasksByStatus(
  db: IDBDatabase,
  status: string,
  limit: number = 25,
  cursorKey?: IDBValidKey[]
): Promise<TaskRecord[]> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('tasks', 'readonly');
    const index = tx.objectStore('tasks').index('status_priority_created');

    const lowerBound = cursorKey ?? [status, 0, 0];
    const upperBound = [status, Infinity, Infinity];
    const range = IDBKeyRange.bound(lowerBound, upperBound, !!cursorKey, false);

    const results: TaskRecord[] = [];
    const request = index.openCursor(range, 'next');

    request.onsuccess = (event) => {
      const cursor = (event.target as IDBRequest<IDBCursorWithValue | null>).result;
      if (cursor && results.length < limit) {
        results.push(cursor.value as TaskRecord);
        cursor.continue();
      } else {
        resolve(results);
      }
    };
    request.onerror = () => reject(request.error);
  });
}

async function safePaginate(
  db: IDBDatabase,
  status: string,
  cursor?: IDBValidKey[]
): Promise<TaskRecord[]> {
  try {
    return await fetchTasksByStatus(db, status, 25, cursor);
  } catch (err) {
    console.error('Cursor iteration failed:', err);
    return [];
  }
}

Browser Compatibility

Index features are broadly stable, but a few behaviors diverge enough to bite production code. The matrix below summarizes the cases worth testing on real hardware.

Feature Chrome / Edge Firefox Safari (incl. iOS 16/17) Notes
Compound (array) key paths Full Full Full Stable everywhere; mind left-prefix rules.
multiEntry array indexes Full Full Full iOS Safari 16 had edge cases with nested arrays; flatten before indexing.
IDBKeyRange.bound open flags Full Full Full Validate boundary types to avoid DataError.
index.getAll / getAllKeys Full Full Full (iOS 16+) Prefer cursors for large ranges regardless of support.
Large index population on upgrade Fast Fast Slower on iOS iOS may abort long upgrade transactions; chunk the migration.

Performance & Scale Notes

At scale the dominant cost is rarely the index lookup itself — it is write amplification and result materialization. Each additional index multiplies the work of every write, so a store with five indexes pays roughly five times the per-record B-tree maintenance cost on insert. Keep indexes to the 2–4 that serve real query paths. On the read side, never call getAll() on an unbounded range; stream with a cursor and an explicit limit, and let IDBKeyRange do the pruning so the engine touches only the relevant B-tree pages rather than the whole store.

Troubleshooting & Common Pitfalls

Symptom Root Cause Resolution
High write latency & GC pauses Over-indexing object stores Limit to 2–4 indexes per store. Audit read paths and remove unused indexes.
Full table scans despite indexes Ignoring left-prefix matching rules Reorder compound index fields to match query predicates exactly.
Main-thread blocking & dropped frames Unbounded cursors on large datasets Use IDBKeyRange.bound() with strict limits and cursor.continue() streaming.
InvalidStateError during schema changes Creating/dropping indexes outside onupgradeneeded Increment database version and apply all schema mutations inside the upgrade callback.
Silent query failures Not waiting for tx.oncomplete before acting on results Attach oncomplete and onerror handlers to every transaction.
Records missing from index results Indexed property is undefined on those records Write a sentinel value so the record is included in the index.

Frequently Asked Questions

How many indexes should I create per IndexedDB object store?

Limit indexes to 2–4 per store unless performance profiling justifies more. Each index adds serialization overhead to writes and consumes additional disk space. Prioritize indexes that cover your highest-frequency read paths and high-cardinality filters.

Can I modify an index after the database is initialized?

No. Index alterations must occur exclusively during the onupgradeneeded event by incrementing the database version. Attempting to modify indexes during standard read/write transactions will throw an InvalidStateError. See Database Schema Migrations for the safe upgrade flow.

Why are my compound index queries slower than single-field lookups?

Compound indexes require exact prefix matching. If your query filters on the second field without specifying the first, IndexedDB cannot utilize the index efficiently. Reorder fields by query selectivity or create separate indexes for distinct access patterns. The mechanics are detailed in Creating Compound Indexes for Multi-Field Filtering.

Does IndexedDB support native full-text search indexing?

No. IndexedDB lacks built-in full-text search capabilities. For text-heavy queries, implement a custom inverted index using a dedicated object store keyed on tokens, or integrate a lightweight search library that builds tokenized indexes on top of IndexedDB primitives.

Related