LEARNING OBJECTIVES ⌵
- Understand why
IDBCursorprevents browser memory exhaustion compared togetAll(). - Control cursor navigation using
openCursor(),continue(),advance(), and traversal directions. - Perform in-place batch mutations and deletions using
cursor.update()andcursor.delete(). - Implement robust cursor-based offset and keyset pagination for high-volume datasets.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine you are auditing 50,000 physical tax forms stored in a filing warehouse.
If you use getAll(), you instruct a forklift to dump all 50,000 heavy file boxes directly onto your small desktop at the exact same moment. Your desk collapses under the weight, paper flies everywhere, and your office runs out of oxygen (a browser tab crash / JavaScript Out-Of-Memory error).
If you use a Cursor (openCursor), you hire a diligent courier who brings you one single folder at a time:
- You inspect Folder #1.
- You stamp it, update it (
cursor.update()), or shred it (cursor.delete()). - You tap the courier on the shoulder and say "Next please" (
cursor.continue()). - The courier swaps out the folder. Your desk only ever holds one single folder in memory at any given millisecond.
- When the cabinet is empty, the courier signals that the job is finished (
cursor === null).
Technical Deep Dive & Specifications
openCursor() Signature & Directions
You can open a cursor on an IDBObjectStore or an IDBIndex:
const request = source.openCursor([queryRange], [direction]);
+----------------------------------------------------------------------------------------------------+
| IDBCURSOR TRAVERSAL DIRECTIONS |
+-------------------+--------------------------------+-----------------------------------------------+
| Direction String | Traversal Order | Duplicate Key Handling |
+-------------------+--------------------------------+-----------------------------------------------+
| `'next'` (default)| Ascending (Lowest ➔ Highest) | Yields all records matching index keys |
+-------------------+--------------------------------+-----------------------------------------------+
| `'nextunique'` | Ascending (Lowest ➔ Highest) | Yields ONLY the first record for duplicate keys|
+-------------------+--------------------------------+-----------------------------------------------+
| `'prev'` | Descending (Highest ➔ Lowest) | Yields all records in reverse order |
+-------------------+--------------------------------+-----------------------------------------------+
| `'prevunique'` | Descending (Highest ➔ Lowest) | Yields ONLY the first record per unique key |
+-------------------+--------------------------------+-----------------------------------------------+
Cursor Anatomy & Methods
When request.onsuccess fires, request.result is an instance of IDBCursorWithValue (or null when finished):
+-----------------------------------------------------------------------------+
| IDBCursor Properties |
+-------------------+---------------------------------------------------------+
| `cursor.key` | The index key or primary key currently under the cursor |
| `cursor.primaryKey` | The primary key of the current record |
| `cursor.value` | The deserialized JavaScript object payload |
+-------------------+---------------------------------------------------------+
| IDBCursor Methods |
+-------------------+---------------------------------------------------------+
| `cursor.continue([key])` | Advances cursor to the next record (or to `key`) |
| `cursor.advance(count)` | Skips forward by `count` records (Offset paging) |
| `cursor.update(newValue)` | Modifies the current record in-place |
| `cursor.delete()` | Deletes the current record from the ObjectStore |
+-----------------------------------------------------------------------------+
The Cursor Recursive Event Loop Pattern
A cursor does not use a synchronous while loop. Instead, each call to cursor.continue() or cursor.advance() triggers another onsuccess event on the same IDBRequest:
request = store.openCursor()
|
v
+---------------------------+
| request.onsuccess | <----------------+
+---------------------------+ |
| |
const cursor = req.result; |
| |
[cursor === null?] |
/ \ |
YES / \ NO |
v v |
[DONE / EXIT] Process cursor.value |
| |
cursor.continue() ------------+
💻 Interactive Code Playground
Starter Code
Save this file as cursors.html and open it in your browser.
Line-by-Line Code Breakdown
- Line 77 (
store.openCursor(null, 'next')): Opens a cursor across all records in ascending order. Passingnullas the first argument indicates no range filter. - Lines 80–87 (
if (cursor) ... cursor.continue()): The standard cursor loop pattern. When records exist,cursoris populated andcursor.continue()is invoked to trigger the next step. When iteration reaches the end,cursorisnull. - Line 97 (
store.openCursor(null, 'prev')): Reverses traversal, streaming from highest primary key (105) down to lowest (101). - Line 124 (
const updateReq = cursor.update(user)): Mutates the record currently under the cursor in-place without needing a separatestore.put()call. - Line 146 (
cursor.delete()): Deletes the exact record currently referenced by the cursor.
Expected Browser Render Output
[03:00:00] 🛠️ Seeded 5 member records.
[03:00:00] ✅ CursorDemoDB ready.
[03:00:05] ▶️ Starting Forward Cursor Stream:
[ID: 101] Alice - Points: 150
[ID: 102] Bob - Points: 40
[ID: 103] Charlie - Points: 320
[ID: 104] Diana - Points: 10
[ID: 105] Evan - Points: 500
🏁 Reached end of forward stream.🏋️ Hands-On Exercise
🎯 The Challenge: Paginated Data Feed with cursor.advance()
Instructions:
- Open database
NewsAppwith storearticles(keyPath: "id", autoIncrement: true). - Seed 15 news articles with titles
"Article 1"through"Article 15". - Implement a function
getPage(pageNumber, pageSize):- Calculate how many items to skip:
skip = (pageNumber - 1) * pageSize. - Open a cursor.
- If
skip > 0, usecursor.advance(skip)on the first step to jump directly to the target offset. - Collect exactly
pageSizeitems and log them.
- Calculate how many items to skip:
- Test by fetching Page 2 with a
pageSizeof 4 (should return Articles 5, 6, 7, and 8).
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Calling Both
cursor.continue()andcursor.advance(): Attempting to call both within the sameonsuccessinvocation throwsInvalidStateError: The cursor is being continued. - Forgetting to Call
cursor.continue(): If you process a record inonsuccessand forget to callcursor.continue(), iteration halts permanently after the first record. - Using Offset Pagination for Millions of Rows:
cursor.advance(1000000)must still traverse 1,000,000 B-Tree leaf nodes. For massive tables, use keyset pagination (IDBKeyRange.lowerBound(lastSeenKey, true)) instead.
💡 Pro Tips
- Use
openKeyCursor()When Payloads Are Not Needed: If you are aggregating keys, checking unique constraints, or counting custom ranges,store.openKeyCursor()returns onlykeyandprimaryKey, skipping all Structured Clone value deserialization. - Combine Cursors with Web Workers: Streaming and filtering 500,000 records using a cursor inside a dedicated Web Worker ensures the main UI thread never drops a single frame.
📌 Key Takeaways
IDBCursoriterates through records one at a time, keeping memory consumption constant regardless of dataset size.- Traversal directions include
'next','prev','nextunique', and'prevunique'. - In-place mutations and removals are executed directly via
cursor.update()andcursor.delete(). cursor.advance(count)skips forward by a given count, enabling offset pagination.openKeyCursor()streams only key metadata, bypassing object payload deserialization for maximum performance.- --