Fix MongoServerError: cursor id not found in MongoDB

intermediate๐Ÿƒ MongoDB2026-03-17| MongoDB 4.xโ€“7.x, Node.js (mongoose / mongodb driver), Python (pymongo), any OS

Error Message

MongoServerError: cursor id not found
#mongodb#cursor#timeout#query

The error scenario

You're iterating over a large MongoDB query โ€” processing thousands of documents, running a migration, or exporting data โ€” and mid-loop you hit this:

MongoServerError: cursor id not found

The first few hundred documents came through fine. Then the cursor died. Your script crashes partway through, and you have to start over.

Almost always, this is a cursor timeout problem. MongoDB's server-side cursor has a default idle timeout of 10 minutes. If processing slows down between batch fetches โ€” heavy computation, external API calls, a large dataset โ€” the server kills the cursor. When your driver asks for the next batch, that cursor ID no longer exists.

Why this happens

MongoDB cursors work in batches. A find() call creates a cursor on the server and returns the first batch โ€” default 101 documents or roughly 1MB. Your driver stores the cursor ID and requests more batches as you iterate.

Here's the catch: the cursor lives on the server, not in your app. If it sits idle for more than 10 minutes with no new batch requests, MongoDB's cursor reaper cleans it up. The next time your driver sends that cursor ID, the server has no idea what you're talking about.

Common triggers:

  • Slow per-document processing inside the loop โ€” API calls, disk writes, heavy computation that takes 2โ€“5 seconds per document
  • Datasets where a single batch of 200 documents takes more than 10 minutes to process
  • Network interruptions that pause iteration
  • A cursor opened but not consumed right away
  • Web app going idle between requests mid-iteration

Quick fix: disable cursor timeout

Fastest option: tell MongoDB to never time out the cursor. That's the noCursorTimeout flag.

Node.js (mongodb driver):

const cursor = db.collection('orders')
  .find({ status: 'pending' })
  .addCursorFlag('noCursorTimeout', true);

for await (const doc of cursor) {
  // slow processing is fine now
  await processDocument(doc);
}

// Always close the cursor when done
await cursor.close();

Node.js (mongoose):

const cursor = Order.find({ status: 'pending' })
  .cursor()
  .addCursorFlag('noCursorTimeout', true);

for await (const doc of cursor) {
  await processDocument(doc);
}

await cursor.close();

Python (pymongo):

cursor = db.orders.find(
    {'status': 'pending'},
    no_cursor_timeout=True
)

try:
    for doc in cursor:
        process_document(doc)  # slow processing OK
finally:
    cursor.close()  # critical โ€” always close manually

One critical caveat: with timeout disabled, MongoDB will never clean up the cursor on its own. You must call cursor.close() in a finally block. Skip that and your process crashes mid-run? The cursor leaks, holds server memory, and sits there until MongoDB restarts or you kill it manually.

Permanent fix: stop relying on long-lived cursors

Disabling timeout is a patch. The real fix is restructuring your code so cursors never stay open long enough to expire.

Option 1: skip/limit batching

Break work into small chunks. Each chunk opens a fresh cursor that completes in seconds:

const BATCH_SIZE = 500;
let skip = 0;

while (true) {
  const docs = await db.collection('orders')
    .find({ status: 'pending' })
    .skip(skip)
    .limit(BATCH_SIZE)
    .toArray();

  if (docs.length === 0) break;

  for (const doc of docs) {
    await processDocument(doc);
  }

  skip += BATCH_SIZE;
}

Each toArray() call fetches and closes the cursor immediately. No open cursor, no timeout risk.

Option 2: cursor batching with maxTimeMS

Need streaming but want a safety net? Set maxTimeMS so the cursor fails fast instead of hanging indefinitely:

const cursor = db.collection('orders')
  .find({ status: 'pending' })
  .maxTimeMS(30000)  // fail if cursor idle > 30s
  .batchSize(200);   // fetch 200 docs per round trip

for await (const doc of cursor) {
  await processDocument(doc);
}

Option 3: aggregation + $out for bulk transforms

Migration or transformation job? Push the work into MongoDB instead of pulling documents to your app:

await db.collection('orders').aggregate([
  { $match: { status: 'pending' } },
  { $addFields: { processedAt: new Date() } },
  { $out: 'orders_processed' }  // write results to new collection
]).toArray();

The aggregation runs entirely server-side. No cursors crossing the network, no timeout risk, no per-document round trips.

Option 4: paginate by _id instead of skip/limit

On large collections โ€” think 1M+ documents โ€” skip gets expensive. MongoDB scans all preceding documents on every page. Paginating by _id avoids that entirely:

const BATCH_SIZE = 500;
let lastId = null;

while (true) {
  const query = lastId
    ? { status: 'pending', _id: { $gt: lastId } }
    : { status: 'pending' };

  const docs = await db.collection('orders')
    .find(query)
    .sort({ _id: 1 })
    .limit(BATCH_SIZE)
    .toArray();

  if (docs.length === 0) break;

  for (const doc of docs) {
    await processDocument(doc);
  }

  lastId = docs[docs.length - 1]._id;
}

Each batch uses a fresh, fast index scan. This is the go-to pattern for large collection scans.

Verify the fix

Don't just assume it worked. Run these checks.

1. Check open cursors on the server:

// In mongosh
db.serverStatus().metrics.cursor

Watch open.noTimeout. If you're using noCursorTimeout, that count should drop back to 0 after your script finishes โ€” confirming cursors closed cleanly.

2. Simulate slow processing:

// Add a deliberate delay to stress-test the fix
for await (const doc of cursor) {
  await new Promise(r => setTimeout(r, 100)); // 100ms per doc
}
// 1,000 docs ร— 100ms = 100 seconds โ€” well past the 10-minute mark at scale
// With the fix in place, this should complete without error

3. Scan MongoDB logs for cursor activity:

grep -i "cursor" /var/log/mongodb/mongod.log | tail -20

Summary

  • MongoServerError: cursor id not found = server-side cursor expired after 10 minutes of idle time
  • Quick fix: noCursorTimeout: true โ€” but you must call cursor.close() in a finally block, no exceptions
  • Better fix: skip/limit batching or _id-based pagination โ€” short-lived cursors, no timeout risk
  • Bulk transforms: run aggregation + $out entirely server-side and skip the cursor problem altogether

Related Error Notes