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 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.
/**
* 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
- Paginating Large IndexedDB Result Sets with Cursors — keyset pagination and infinite scroll built on these cursor patterns.
- Indexing Strategies for Fast Queries — narrowing the cursor range with the right index.
- IndexedDB Transaction Management — scoping the transactions that keep cursors valid.
- Handling Deadlocks in IndexedDB Read/Write Transactions — diagnosing the contention that aborts long scans.
- IndexedDB Architecture & Advanced Patterns — the parent guide for the broader storage architecture.