Query Optimization & Cursors: High-Performance IndexedDB Patterns for Offline-First Apps

When building offline-first applications, naive data retrieval quickly becomes a bottleneck. Loading entire object stores into memory triggers main-thread blocking and rapid garbage collection pressure. The solution lies in leveraging cursor-based iteration to process records incrementally. As a core component of IndexedDB Architecture & Advanced Patterns, cursor optimization ensures predictable memory footprints and responsive UIs even when syncing megabytes of cached state. This guide is the companion to Indexing Strategies for Fast Queries: indexes decide which records the engine touches, cursors decide how you stream them out.

Cursor lifecycle across yielded batches A diagram showing a cursor processing a batch of records, checkpointing the last primary key, yielding to the event loop, then resuming from the checkpoint in a fresh transaction. Open cursor readonly tx · range Process batch N records · continue() Checkpoint key save primaryKey Yield to event loop setTimeout(0)

The Cursor API: Methods, Parameters, and Return Values

Cursors are created from an IDBObjectStore or an IDBIndex and are then driven with a small set of advance methods. Every creation call returns an IDBRequest; the cursor itself arrives asynchronously on that request’s result, and the same request fires onsuccess again on each advance. Understanding these signatures up front removes most of the confusion around why cursor code is shaped the way it is.

// Creation — both return an IDBRequest, not a cursor
store.openCursor(query?: IDBKeyRange | IDBValidKey | null,
                 direction?: IDBCursorDirection): IDBRequest<IDBCursorWithValue | null>;
store.openKeyCursor(query?: IDBKeyRange | IDBValidKey | null,
                    direction?: IDBCursorDirection): IDBRequest<IDBCursor | null>;

// Advancement — all return void and re-fire the request's onsuccess
cursor.continue(key?: IDBValidKey): void;
cursor.continuePrimaryKey(key: IDBValidKey, primaryKey: IDBValidKey): void;
cursor.advance(count: number): void;

// Mutation from an open cursor (requires a readwrite transaction)
cursor.update(value: any): IDBRequest<IDBValidKey>;
cursor.delete(): IDBRequest<undefined>;
Member Parameter Type Meaning / return
openCursor query IDBKeyRange | IDBValidKey | null Restricts iteration to a key range; null scans the whole store. Returns an IDBRequest whose result is an IDBCursorWithValue (record value included) or null at the end.
openKeyCursor query, direction same as above Same traversal but the cursor exposes only keys, not value — cheaper when you never read the record body.
direction 'next' | 'nextunique' | 'prev' | 'prevunique' Traversal order; the unique variants collapse duplicate index keys to the first match.
continue key IDBValidKey? Advances to the next position, or jumps forward to key when supplied. Returns void; schedules the next onsuccess.
continuePrimaryKey key, primaryKey IDBValidKey On an index cursor, resumes at an exact (indexKey, primaryKey) pair — the precise seek that makes stable keyset pagination possible.
advance count number Skips count records. Convenient but O(n) — it still walks every skipped record internally.
cursor.primaryKey IDBValidKey The store key of the record under the cursor; the value you checkpoint to resume iteration later.

The critical mental model: continue(), continuePrimaryKey(), and advance() all return undefined. They are not awaitable — they queue another onsuccess on the originating request. Every wrapper in this guide exists to translate that callback shape into a promise you can await.

Cursor Lifecycle and Memory Management

An IDBCursor operates as a lazy iterator over an index or object store. Unlike getAll(), which materializes every record in RAM, openCursor() maintains a lightweight pointer to the underlying B-tree. Each cursor.continue() call advances the pointer without duplicating data, keeping heap allocations flat regardless of dataset size. However, developers must strictly respect transaction boundaries. A cursor remains valid only while its parent transaction is active. If the transaction commits or aborts prematurely, subsequent continue() calls throw InvalidStateError.

Proper scoping requires aligning cursor iteration with IndexedDB Transaction Management principles, ensuring read-only transactions are explicitly declared to prevent unnecessary write-lock overhead and reduce contention on shared object stores.

/**
 * Production-ready cursor wrapper with explicit transaction scoping.
 * processRecord() is called synchronously for each record.
 */
function iterateStore(
  db: IDBDatabase,
  storeName: string,
  processRecord: (value: unknown) => void,
  direction: IDBCursorDirection = 'next'
): Promise<void> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const store = tx.objectStore(storeName);
    const request = store.openCursor(null, direction);

    request.onsuccess = (e) => {
      const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
      if (cursor) {
        processRecord(cursor.value);
        cursor.continue(); // Advance — triggers the next onsuccess call
      } else {
        resolve(); // cursor is null — iteration complete
      }
    };

    request.onerror = (e) => {
      const error = (e.target as IDBRequest).error;
      reject(new Error(`Cursor iteration failed: ${error?.name || 'Unknown'}`));
    };

    tx.onabort = (e) => {
      reject(
        new Error(
          `Transaction aborted: ${(e.target as IDBTransaction).error?.message}`
        )
      );
    };
  });
}

Key point: IDBCursor.continue() returns undefined — it is not a promise. It schedules the next onsuccess callback call. All cursor advancement must happen inside the onsuccess handler; never await cursor.continue().

Yielding to the Main Thread During Long Iterations

Synchronous cursor loops will freeze the UI on mobile devices, especially during heavy hydration phases. To maintain 60 fps rendering and avoid Lighthouse long-task warnings, cursor iteration must yield back to the event loop. The correct way to do this is to break large datasets into batches and process each batch in its own transaction, or to yield between records using setTimeout:

/**
 * Cursor iteration that yields to the event loop every batchSize records.
 * Each yield opens a fresh continuation from a stored checkpoint key.
 */
async function iterateInBatches(
  db: IDBDatabase,
  storeName: string,
  processRecord: (value: unknown) => void,
  batchSize = 75
): Promise<void> {
  let lastKey: IDBValidKey | undefined;

  while (true) {
    const range = lastKey
      ? IDBKeyRange.lowerBound(lastKey, true) // exclusive lower bound
      : null;

    const batchDone = await new Promise<IDBValidKey | null>((resolve, reject) => {
      const tx = db.transaction(storeName, 'readonly');
      const store = tx.objectStore(storeName);
      const request = store.openCursor(range);
      let count = 0;
      let lastSeenKey: IDBValidKey | null = null;

      request.onsuccess = (e) => {
        const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
        if (!cursor || count >= batchSize) {
          resolve(lastSeenKey);
          return;
        }
        processRecord(cursor.value);
        lastSeenKey = cursor.primaryKey;
        count++;
        cursor.continue();
      };

      request.onerror = () => reject(request.error);
    });

    if (batchDone === null) break; // No more records

    lastKey = batchDone;
    // Yield to the event loop before starting the next batch
    await new Promise((r) => setTimeout(r, 0));
  }
}

This technique pairs effectively with Indexing Strategies for Fast Queries, allowing you to narrow the initial cursor range using IDBKeyRange before entering the loop. The checkpoint-and-resume shape shown here — store the last primaryKey, reopen with an exclusive lowerBound — is exactly the keyset technique formalized in Paginating Large IndexedDB Result Sets with Cursors, where each “batch” becomes a user-facing page.

Keyset Pagination vs Offset Skipping

The instinct carried over from SQL is to paginate with an offset: skip the first N rows, take the next M. IndexedDB has cursor.advance(n) which looks like an offset, but it still walks past every skipped record internally, so page 500 costs 500 times more than page 1. The scalable alternative is keyset (cursor) pagination: remember the last key you saw and reopen the range from just past it.

Offset skipping versus keyset pagination cost Two panels over the same run of key-ordered records. On the left, offset skipping with cursor.advance steps past every record before the target page, so cost grows with page depth — O(n) per page. On the right, keyset pagination reopens the cursor at the last key's exact B-tree position with a single seek, so cost stays flat — O(log n) per page. Offset skipping cursor.advance(n) page steps 1…N records in key order → O(n) per page walks past every skipped record Keyset pagination lowerBound(lastKey, true) page single seek jumps to the last key ↑ O(log n) per page reopens at the exact B-tree position
/**
 * Fetch one page of `pageSize` records after `afterKey` using keyset pagination.
 * Returns the rows plus the cursor key to pass into the next call.
 */
function fetchPage<T>(
  db: IDBDatabase,
  storeName: string,
  pageSize: number,
  afterKey?: IDBValidKey
): Promise<{ rows: T[]; nextKey: IDBValidKey | null }> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const store = tx.objectStore(storeName);
    const range = afterKey ? IDBKeyRange.lowerBound(afterKey, true) : null;
    const request = store.openCursor(range);

    const rows: T[] = [];
    let nextKey: IDBValidKey | null = null;

    request.onsuccess = (e) => {
      const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
      if (cursor && rows.length < pageSize) {
        rows.push(cursor.value as T);
        nextKey = cursor.primaryKey;
        cursor.continue();
      } else {
        // If we filled the page there may be more; otherwise we hit the end.
        resolve({ rows, nextKey: rows.length === pageSize ? nextKey : null });
      }
    };
    request.onerror = () => reject(request.error);
  });
}

Keyset pagination stays O(log n) per page no matter how deep the user scrolls, because each page reopens at a precise B-tree position instead of counting from the start. The full treatment, including stable ordering and infinite-scroll integration, is in Paginating Large IndexedDB Result Sets with Cursors.

Cross-Browser Quirks and Production Fallbacks

Browser engines diverge significantly in cursor implementation. Safari historically had issues with cursors opened across microtask boundaries (resolved in modern WebKit), while Chromium aggressively caches cursor values in the V8 heap. Firefox enforces strict transaction timeouts that can abort long-running iterations on low-end devices. A robust fallback strategy involves detecting runtime constraints and switching to bounded IDBKeyRange queries or chunked getAll() calls when needed:

/**
 * Cross-browser cursor fallback with feature detection and bounded queries.
 */
function safeCursorFallback(
  store: IDBObjectStore,
  keyRange: IDBKeyRange | null,
  chunkLimit = 2000
): IDBRequest<IDBCursorWithValue | null> | IDBRequest<unknown[]> {
  const isLowMemory =
    (navigator as Navigator & { deviceMemory?: number }).deviceMemory !== undefined
      ? (navigator as Navigator & { deviceMemory?: number }).deviceMemory! < 4
      : false;

  if (isLowMemory) {
    // Fallback to bounded getAll to reduce cursor overhead on low-memory devices
    return store.getAll(keyRange, chunkLimit);
  }

  return store.openCursor(keyRange);
}

The behaviors that matter most in production are summarized below. Test the iOS Safari rows on real hardware, since the simulator does not reproduce its eviction and timeout characteristics.

Behavior Chrome / Edge Firefox Safari (incl. iOS 16/17) Mitigation
Long-running cursor transactions Tolerant Strict timeout Aggressive abort on iOS Batch and yield; checkpoint the primaryKey.
Cursor across microtask boundary OK OK Fixed in modern WebKit Keep continue() inside onsuccess; never await it.
getAll with count limit Full Full Full (iOS 16+) Use as a bounded fallback on low-memory devices.
navigator.deviceMemory Supported Not exposed Not exposed Treat absence as “assume constrained” and prefer cursors.

Async Error Handling and Transaction Recovery

Cursor operations are inherently asynchronous and prone to DataError, TransactionInactiveError, and QuotaExceededError. Wrapping cursor logic in a retry mechanism with exponential backoff mitigates transient I/O failures. Implement a checkpoint system that stores the last processed cursor.primaryKey to enable seamless resumption after recovery.

/**
 * Resilient cursor runner with quota checks, exponential backoff, and checkpoint recovery.
 */
async function resilientCursorRun(
  db: IDBDatabase,
  storeName: string,
  processRecord: (value: unknown) => void,
  startKey?: IDBValidKey
): Promise<void> {
  const MAX_RETRIES = 3;
  let retries = 0;
  let currentKey = startKey;

  // Pre-flight quota estimation
  const { usage = 0, quota = 1 } = await navigator.storage.estimate();
  if (usage / quota > 0.85) {
    throw new Error('Storage quota critically low. Aborting cursor iteration.');
  }

  while (retries <= MAX_RETRIES) {
    try {
      await new Promise<void>((resolve, reject) => {
        const range = currentKey
          ? IDBKeyRange.lowerBound(currentKey, true)
          : null;
        const tx = db.transaction(storeName, 'readonly');
        const store = tx.objectStore(storeName);
        const cursorReq = store.openCursor(range);

        cursorReq.onsuccess = (e) => {
          const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
          if (cursor) {
            processRecord(cursor.value);
            currentKey = cursor.primaryKey; // Checkpoint
            cursor.continue();
          } else {
            resolve();
          }
        };
        cursorReq.onerror = () => reject(cursorReq.error);
        tx.onerror = () => reject(tx.error);
      });

      return; // Success
    } catch (err) {
      const error = err as DOMException;
      if (error.name === 'QuotaExceededError') {
        throw new Error(
          'Quota exceeded during iteration. Clear unused caches and retry.'
        );
      }
      if (error.name === 'TransactionInactiveError') {
        retries++;
        const delay = Math.pow(2, retries) * 100;
        await new Promise((r) => setTimeout(r, delay));
        continue; // Retry from last checkpoint
      }
      throw err; // Unrecoverable
    }
  }
  throw new Error('Max retries reached; cursor iteration abandoned.');
}

For deadlock-class failures and how scope contention produces TransactionInactiveError in the first place, see Handling Deadlocks in IndexedDB Read/Write Transactions.

Performance & Scale Notes

The throughput ceiling for cursor iteration is the per-record work in processRecord, not the cursor advance itself. Keep that callback cheap — defer DOM writes, avoid synchronous JSON.parse on every record where a structured value is already stored, and never accumulate the entire result set in an array you keep referencing. For datasets in the tens of thousands, a batch size around 50–100 records per event-loop yield keeps the longest task under the 50 ms interactivity budget on mid-range phones. Always pair the cursor with an IDBKeyRange narrowed by an appropriate index so the engine skips B-tree pages instead of visiting every record.

Frequently Asked Questions

When should I use a cursor instead of getAll()?

Use a cursor whenever the result set could be large or unbounded. getAll() materializes every matching record in memory at once, which spikes the heap and can trigger garbage-collection pauses or QuotaExceededError on low-memory devices. A cursor streams one record at a time with a flat memory profile. Reserve getAll() with a count limit for small, bounded reads.

Why does cursor.continue() not return a promise?

IDBCursor.continue() returns undefined; it merely schedules the next onsuccess callback on the same request. All advancement must happen inside that handler. Awaiting it does nothing useful and will leave your loop hanging. To get an async-iterator feel, wrap the request in a Promise that resolves when the cursor is exhausted, as shown throughout this guide.

How do I keep a long cursor scan from freezing the UI?

Process records in batches and yield to the event loop between them with setTimeout(r, 0), reopening a fresh transaction from a checkpointed primaryKey each round. This caps individual task length so the main thread can paint and respond to input. The same batching becomes user-facing pages in Paginating Large IndexedDB Result Sets with Cursors.

Related