← back

Cursors

FILE  16_cursors
TOPIC  Lifecycle · Batching · Iteration · noCursorTimeout · Pagination
LEVEL  Foundation
01
What is a Cursor
A pointer to the result set of a query
concept

When you run find(), MongoDB does not immediately return all matching documents. Instead, it returns a cursor — a pointer to the result set stored server-side. Documents are fetched in batches as you iterate.

Why Cursors Exist

  • Avoid loading millions of documents into memory at once
  • Enable streaming/lazy consumption — process one batch at a time
  • Allow cancellation mid-way through a large result set
  • Support chaining of sort(), limit(), skip() before execution

Cursor in the Shell

// find() returns a cursor — not documents directly
const cursor = db.users.find({ active: true })

// The mongosh shell auto-iterates and prints the first batch
db.users.find({ active: true })
// Prints first 20 documents, then shows "Type 'it' for more"
// "it" iterates the cursor to the next batch

// In Node.js — cursor is not consumed until you iterate
const cursor = collection.find({ active: true })
// Nothing has been sent to the DB yet in terms of data transfer
for await (const doc of cursor) {
  console.log(doc)   // fetches in batches as the loop runs
}
NOTE
In the mongosh shell, the cursor is automatically iterated and the first 20 documents are printed. Type it (iterate) to get the next batch. In drivers (Node.js, Python, etc.), you must explicitly iterate — the cursor does nothing on its own until consumed.
02
Cursor Lifecycle
Creation · Active · Exhausted · Timeout · Closed
lifecycle
Createdfind() called, cursor ID allocated server-side
ActiveClient iterating, batches fetched on demand
ExhaustedAll documents consumed, cursor auto-closed
Timed Out10-min inactivity — server auto-destroys
ClosedManual close() or session end

The 10-Minute Inactivity Timeout

MongoDB automatically destroys a cursor that has been inactive (no getMore request) for 10 minutes. If your application pauses iteration for longer than this, the cursor is gone server-side.

// After 10 minutes of inactivity...
cursor.hasNext()
// Error: cursor id N not found (CursorNotFound error)
// The server has already destroyed the cursor
DANGER
Do not hold a cursor open across user-facing delays, batch processing pauses, or anything that might exceed 10 minutes. Either paginate with _id-based queries, use noCursorTimeout() (with mandatory manual close), or ensure your processing loop is fast enough.

Cursor Exhaustion vs Timeout

StateCauseCursor Still on Server?
Exhausted All documents iterated — hasNext() returns false No — auto-closed cleanly
Timed out Inactive for 10 minutes No — destroyed with error on next access
Manually closed cursor.close() called No — released explicitly
Session ended Client session / connection closed No — cleaned up
03
Batch Fetching
How documents flow from server to client
batching

MongoDB does not send all documents in one network round-trip. It sends them in batches. The client fetches the next batch automatically as the current one is consumed.

Default Batch Sizes

BatchSizeNotes
First batch 101 documents or 1 MB — whichever is smaller Returned inline with the query response
Subsequent batches Up to 16 MB per getMore request Client sends a getMore command; no extra round-trip for current batch
// The first call to find() triggers the query + returns the first batch
// Subsequent calls to hasNext()/next() consume the local batch, then auto-fetch

// Visualising the flow for 250 documents (default batch size)
// Step 1 — Query sent, first 101 docs returned in response
// Step 2 — Client consumed all 101; driver sends getMore
// Step 3 — Server returns remaining 149 docs (under 16 MB)
// Step 4 — Client consumed all; hasNext() → false; cursor auto-closed

Controlling Batch Size with batchSize()

// Override the default first-batch size
db.users.find().batchSize(50)
// First and subsequent batches will be at most 50 documents each
// Useful to reduce memory footprint per batch

// In Node.js driver
const cursor = collection.find({}).batchSize(100)
for await (const doc of cursor) {
  await processDocument(doc)
}
// Driver automatically issues getMore commands as batches are consumed
TIP
Smaller batch sizes reduce peak memory usage but increase the number of network round-trips. Larger batches are more efficient for bulk processing but consume more memory per round-trip. The default (101 for first, 16 MB for rest) works well for most use cases.
04
Iteration Methods
hasNext · next · forEach · toArray · for-await
iteration

hasNext() and next()

// Manual iteration — check then fetch
const cursor = db.users.find({ active: true })

while (cursor.hasNext()) {
  const doc = cursor.next()
  printjson(doc)
}
// hasNext() returns true if there are more documents in current batch or on server
// next() fetches and returns the next document, advancing the cursor

forEach()

// Apply a function to each document — cleaner syntax
db.users.find({ active: true }).forEach(doc => {
  print(doc.name + ": " + doc.email)
})
// forEach automatically iterates the entire cursor
// Cursor is exhausted (and closed) after forEach completes

// In Node.js driver
await collection.find({ active: true }).forEach(async (doc) => {
  await sendEmail(doc.email)
})

toArray()

// Load ALL documents into memory at once
const allUsers = db.users.find({ active: true }).toArray()
// Returns a JavaScript array containing every document
// Fine for small result sets; dangerous for large ones (OOM risk)

// Node.js equivalent
const docs = await collection.find({ status: "active" }).toArray()
console.log(docs.length)
WARN
toArray() loads the entire result set into memory. Never use it on queries that might return thousands of documents — use forEach, for await...of, or paginate instead.

for await...of (Node.js)

// Modern async iteration — best practice in Node.js
const cursor = collection.find({ active: true })

for await (const doc of cursor) {
  await processDoc(doc)
}
// Fetches batches lazily, processes one doc at a time
// Memory-efficient for large result sets
// Cursor is automatically closed when loop completes or throws

Iteration Method Comparison

MethodMemory UsageBest For
hasNext() + next() One batch at a time Manual control, conditional iteration
forEach() One batch at a time Apply logic to every doc; clean syntax
toArray() Full result set at once Small result sets, tests, one-off scripts
for await...of One batch at a time Node.js async processing — modern best practice
05
noCursorTimeout
Prevent the 10-minute auto-expiry
long-running

noCursorTimeout() disables the automatic 10-minute idle timeout for a cursor. This is necessary when processing each document takes a long time and the cursor might go idle between getMore requests.

Usage

// Shell
const cursor = db.heavyCollection.find({}).noCursorTimeout()

// Node.js driver
const cursor = collection.find({}).noCursorTimeout(true)

// Always manually close the cursor when done
try {
  for await (const doc of cursor) {
    await slowProcessing(doc)   // might take minutes per document
  }
} finally {
  await cursor.close()          // REQUIRED — prevents server-side cursor leak
}
DANGER
When using noCursorTimeout() you must close the cursor manually with cursor.close() when finished (even on error, hence the finally block). If you don't, the cursor remains open on the server indefinitely, consuming server resources and counting toward MongoDB's open cursor limit.

When to Use noCursorTimeout

  • Batch data migration / ETL jobs with slow per-document processing
  • Export operations where each document triggers an external API call
  • Long-running aggregation consumers
  • Any processing loop where inter-document pause could exceed 10 minutes

Alternatives to noCursorTimeout

// ALTERNATIVE 1 — Periodically refresh the cursor with a small $limit
// If you need to pause, save your position and start a new cursor
let lastId = null
while (true) {
  const filter = lastId ? { _id: { $gt: lastId } } : {}
  const docs = await collection
    .find(filter)
    .sort({ _id: 1 })
    .limit(100)
    .toArray()

  if (docs.length === 0) break

  for (const doc of docs) {
    await slowProcessing(doc)
  }
  lastId = docs[docs.length - 1]._id
}
// No cursor kept open — safe to pause between page fetches
06
Cursor-Based Pagination
Efficient pagination using _id as a bookmark
pagination

Traditional skip()-based pagination degrades at large offsets because MongoDB must scan and discard the skipped documents. Cursor-based pagination (also called keyset pagination) uses the last seen _id as a bookmark — O(log n) regardless of page number.

The Problem with skip()

// Page 1: skip 0, take 20 — fast (scans 20 docs)
db.products.find().sort({ _id: 1 }).skip(0).limit(20)

// Page 100: skip 1980, take 20 — SLOW (scans 2000 docs, discards 1980)
db.products.find().sort({ _id: 1 }).skip(1980).limit(20)
// Performance degrades linearly with page number
// Also: inserted docs between pages cause duplicates/skips

_id-Based Keyset Pagination

// Page 1 — no bookmark needed
const page1 = db.products
  .find({ status: "active" })
  .sort({ _id: 1 })
  .limit(20)
  .toArray()

// Save the last _id as the bookmark
const lastId = page1[page1.length - 1]._id

// Page 2 — use bookmark, no skip
const page2 = db.products
  .find({ status: "active", _id: { $gt: lastId } })
  .sort({ _id: 1 })
  .limit(20)
  .toArray()
// MongoDB uses the index on _id to jump directly to the right position
// O(log n) regardless of how deep you are in the result set

// Page N — same pattern, carry the bookmark forward
const lastId_N = page2[page2.length - 1]._id
const pageN1 = db.products
  .find({ status: "active", _id: { $gt: lastId_N } })
  .sort({ _id: 1 })
  .limit(20)
  .toArray()

Pagination by Custom Sort Field

// Sort by createdAt — use both createdAt and _id to break ties
const lastDoc = page1[page1.length - 1]

const page2 = db.posts.find({
  $or: [
    { createdAt: { $gt: lastDoc.createdAt } },
    { createdAt: lastDoc.createdAt, _id: { $gt: lastDoc._id } }
  ]
})
.sort({ createdAt: 1, _id: 1 })
.limit(20)
.toArray()
// The $or handles ties on createdAt — no duplicates across pages

skip() vs keyset Comparison

skip() PaginationKeyset Pagination
Deep page performanceO(n) — degradesO(log n) — stable
Random page accessyes (jump to any page)no (sequential only)
Stable under insertsno — duplicates/skipsyes — bookmark is stable
Requires sort field indexrecommendedrequired
Use caseSmall datasets, UI with page numbersInfinite scroll, feeds, large datasets
TIP
MongoDB ObjectIds are monotonically increasing (they embed a timestamp), so sorting by _id: 1 gives chronological order without a separate createdAt field. This makes the _id field the ideal keyset pagination bookmark for insertion-ordered data.
07
Cursor Methods Reference
All cursor methods at a glance
reference
MethodPurposeNotes
cursor.hasNext()Returns true if more documents remainTriggers batch fetch if current batch exhausted
cursor.next()Returns next document, advances cursorThrows if no more documents — always check hasNext first
cursor.forEach(fn)Applies function to every documentAuto-closes cursor on exhaustion
cursor.toArray()Loads all remaining docs into arrayAvoid on large result sets — memory risk
cursor.close()Explicitly release cursor on serverRequired after noCursorTimeout()
cursor.noCursorTimeout()Disable 10-min idle timeoutMust manually close() the cursor
cursor.batchSize(n)Override default batch sizeDoes not change total document count
cursor.sort(doc)Sort result setMust be called before iteration begins
cursor.limit(n)Cap result count at nApplied server-side before batch delivery
cursor.skip(n)Skip first n documentsExpensive at large offsets — use keyset instead
cursor.count()Count matching documentsDeprecated in modern drivers — use countDocuments()
cursor.explain()Show query execution planPass "executionStats" for full detail
cursor.hint(index)Force use of a specific indexUseful for debugging query plans
cursor.project(doc)Specify fields to include/excludeSame as second arg to find()

Common Usage Patterns

// Efficient large-dataset processing (Node.js)
const cursor = collection
  .find({ status: "pending" })
  .sort({ createdAt: 1 })
  .batchSize(200)

try {
  for await (const doc of cursor) {
    await processDocument(doc)
  }
} finally {
  await cursor.close()
}

// Count without loading documents
const count = await collection.countDocuments({ status: "active" })
// estimatedDocumentCount() is faster but uses metadata (no filter)
const approx = await collection.estimatedDocumentCount()

// Debug a slow query
db.users.find({ email: /gmail/ }).explain("executionStats")
// Look for: totalDocsExamined vs nReturned
// A good index: nReturned ≈ totalDocsExamined
// Full scan warning: totalDocsExamined >> nReturned

// Read with projection on cursor
db.users
  .find({ active: true })
  .project({ name: 1, email: 1, _id: 0 })
  .sort({ name: 1 })
  .limit(50)
NOTE
estimatedDocumentCount() reads collection metadata and is O(1) — very fast. countDocuments() runs a query and honours filters — use it when you need an exact filtered count. cursor.count() is deprecated in driver v4+ in favour of countDocuments().