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 callcursor.close()in afinallyblock, no exceptions - Better fix:
skip/limitbatching or_id-based pagination โ short-lived cursors, no timeout risk - Bulk transforms: run aggregation +
$outentirely server-side and skip the cursor problem altogether

