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 foundational component of IndexedDB Architecture & Advanced Patterns, cursor optimization ensures predictable memory footprints and responsive UIs even when syncing megabytes of cached state.
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.
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);
}
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.');
}
Optimizing IndexedDB queries through disciplined cursor management transforms offline-first applications from sluggish caches into resilient data engines. By combining batch-yield patterns, cross-browser fallbacks, and robust error recovery, frontend teams can deliver consistent performance across constrained mobile networks and legacy devices.