Cursors: batches, getMore, timeouts and tailable cursors
A query does not return all its results at once. The server keeps a cursor and sends documents in batches. Learn how batches and getMore work, why cursors time out in the middle of a long job, how to find leaked cursors, and how tailable cursors follow a capped collection.
- Explain the first batch of 101 documents, getMore batches and the 16 MiB limit
- Tune batchSize for exports and for small pages
- Avoid CursorNotFound in long jobs: idle timeout, sessions and noCursorTimeout
- Find open and timed-out cursors with serverStatus and $currentOp
- Use a tailable cursor on a capped collection and handle CappedPositionLost
conceptThe concept
A find or aggregate opens a cursor on the server and returns the first batch: 101 documents by default, or fewer if they would exceed 16 MiB. If more results remain, the reply carries a non-zero cursor id. The client then sends getMore with that id to fetch the next batch. Later batches have no default document count; they are limited only by the 16 MiB message size. A cursor id of 0 means the server has nothing left and has closed the cursor.
batchSize sets how many documents go in each batch. limit is different: it caps the total. mongosh shows 20 documents at a time and prints "Type "it" for more"; that number is the shell's displayBatchSize, a display setting, not the network batch.
Lifetime. An idle cursor is closed after cursorTimeoutMillis, 10 minutes by default. The next getMore then fails with CursorNotFound. noCursorTimeout disables that timer, but since 3.6 every cursor also belongs to a session, and a session that is idle for 30 minutes (localLogicalSessionTimeoutMinutes) expires and takes its cursors with it. Long jobs either keep each batch short, refresh the session, or page through the data by _id instead of holding one cursor for hours.
Tailable cursors work only on capped collections. Like tail -f, they stay open after the last document, and a later getMore returns documents inserted since then. With awaitData, the getMore waits up to maxTimeMS for new data instead of returning an empty batch at once.
whyWhy it matters
Most "random" failures in batch jobs and exports are cursor problems. A job processes each batch slowly and hits CursorNotFound after an hour of work. A service never closes its cursors and the open cursor count climbs until memory pressure appears. Or an export uses tiny batches and spends most of its time on network round trips. All three are easy to diagnose once you know how batches and timeouts work.
Use caseReal-world scenario
A nightly job reads 2 million orders, calls a fraud service for each one and writes a score. It fails every night after about 40 minutes with cursor id ... not found. Each batch of 101 documents takes 12 minutes to process, so the cursor sits idle longer than 10 minutes. The fix is not noCursorTimeout: that would leak cursors when the job crashes, and the 30-minute session timeout would still apply. Instead the job pages by _id in chunks of 1,000, records the last _id after each chunk, and can resume from there after a restart.
Try itExamples
Run and edit these in the interactive lesson.
const r = db.runCommand({ find: "orders", filter: { status: "cancelled" }, projection: { _id: 0, orderNo: 1 }, batchSize: 3 })
r.cursor
const more = db.runCommand({ getMore: r.cursor.id, collection: "orders", batchSize: 5 })
;({ nextBatch: more.cursor.nextBatch.length, stillOpen: more.cursor.id !== 0 })
db.runCommand({ killCursors: "orders", cursors: [r.cursor.id] })const r = db.runCommand({ find: "orders", filter: { status: "delivered" }, projection: { _id: 1 } })
const rest = db.runCommand({ getMore: r.cursor.id, collection: "orders" })
;({ firstBatch: r.cursor.firstBatch.length, nextBatch: rest.cursor.nextBatch.length, closedAfter: rest.cursor.id === 0 })const c = db.orders.find({ status: "shipped" }).batchSize(50)
const before = c.objsLeftInBatch()
c.next()
;({ before, afterOne: c.objsLeftInBatch() })const r = db.runCommand({ find: "events", batchSize: 10 })
sleep(11 * 60 * 1000)
db.runCommand({ getMore: r.cursor.id, collection: "events" })const r = db.runCommand({ find: "events", batchSize: 10, noCursorTimeout: true })
sleep(11 * 60 * 1000)
const m = db.runCommand({ getMore: r.cursor.id, collection: "events", batchSize: 10 })
const open = db.serverStatus().metrics.cursor.open
db.runCommand({ killCursors: "events", cursors: [r.cursor.id] })
;({ got: m.cursor.nextBatch.length, openBeforeKill: open })db.adminCommand({ getParameter: 1, cursorTimeoutMillis: 1 })db.lab_feed.drop()
db.createCollection("lab_feed", { capped: true, size: 1048576, max: 5 })
db.lab_feed.insertMany([{ _id: 1, msg: "boot" }, { _id: 2, msg: "ready" }]).acknowledged
const t = db.runCommand({ find: "lab_feed", tailable: true, awaitData: true })
t.cursor.firstBatch
db.lab_feed.insertOne({ _id: 3, msg: "job started" }).acknowledged
db.runCommand({ getMore: t.cursor.id, collection: "lab_feed", maxTimeMS: 1000 }).cursor.nextBatch
db.runCommand({ getMore: t.cursor.id, collection: "lab_feed", maxTimeMS: 1000 }).cursor.nextBatch
db.runCommand({ killCursors: "lab_feed", cursors: [t.cursor.id] })db.lab_feed2.drop()
db.createCollection("lab_feed2", { capped: true, size: 1048576, max: 3 })
db.lab_feed2.insertOne({ _id: 1 })
const t = db.runCommand({ find: "lab_feed2", tailable: true })
db.lab_feed2.insertMany([{ _id: 2 }, { _id: 3 }, { _id: 4 }])
db.runCommand({ getMore: t.cursor.id, collection: "lab_feed2" })db.runCommand({ find: "orders", tailable: true })// idle cursors with their age, namespace and client
db.getSiblingDB("admin").aggregate([
{ $currentOp: { allUsers: true, idleCursors: true } },
{ $match: { type: "idleCursor" } },
{ $project: { ns: 1, "cursor.cursorId": 1, "cursor.noCursorTimeout": 1, "cursor.lastAccessDate": 1, "cursor.nDocsReturned": 1, appName: 1 } }
])
// counters to graph and alert on
db.serverStatus().metrics.cursor // open.total, open.noTimeout, timedOut, lifespan// Node.js: stream with a sensible batch size; the driver sends getMore for you
const cursor = orders.find({ status: "delivered" }, { batchSize: 1000, projection: { _id: 1, total: 1 } });
for await (const doc of cursor) { await score(doc); }
// Resumable paging by _id instead of one cursor held for hours
let last = checkpoint.load(); // MinKey on the first run
for (;;) {
const page = await orders.find({ _id: { $gt: last } }).sort({ _id: 1 }).limit(1000).toArray();
if (!page.length) break;
for (const doc of page) await score(doc);
last = page[page.length - 1]._id;
checkpoint.save(last);
}internalsUnder the hood
Each open cursor holds its query plan, its position and, for some plans, buffered results on the server. A background thread checks for idle cursors every few seconds (clientCursorMonitorFrequencySecs, 4 by default) and kills those older than cursorTimeoutMillis.
A cursor is not a snapshot. Writes that happen between batches can be visible, and an update that changes the indexed field the query is walking can make a document appear twice or not at all. For a consistent point-in-time read outside a transaction, use readConcern: { level: "snapshot" } on find or aggregate (5.0 and later), or iterate on an immutable key such as _id.
A capped collection keeps documents in insertion order and overwrites the oldest when it is full. A tailable cursor remembers the last position it read. If the writer laps a slow reader, that position is overwritten and the next getMore fails with CappedPositionLost.
Avoid / preferDo this, not that
db.orders.find({}).noCursorTimeout().forEach(doc => slowExternalCall(doc))let last = MinKey
// fetch a page, process it, remember the last _id, repeat
db.orders.find({ _id: { $gt: last } }).sort({ _id: 1 }).limit(1000)A cursor without a timeout leaks when the client dies and still expires with its session. Short pages keyed on _id never time out and let the job resume after a crash.
db.orders.find({ status: "delivered" }).batchSize(1) // exportdb.orders.find({ status: "delivered" }).batchSize(1000)A batch size of 1 turns an export of a million documents into a million round trips. Use hundreds to a few thousand for bulk reads, and keep the default for small pages.
PitfallsCommon mistakes
- Setting noCursorTimeout on every query "to be safe", then leaking cursors whenever a client crashes.
- Assuming noCursorTimeout protects a multi-hour job: the 30-minute session timeout still kills the cursor.
- Doing slow work per document while holding a cursor, instead of fetching a page and closing it.
- Confusing batchSize with limit, or the shell's 20-document display with the network batch.
- Tailing a capped collection with a reader that cannot keep up, and not handling CappedPositionLost.
LimitsWhen not to use it
Do not use tailable cursors for change data capture or event feeds; use change streams. Do not raise cursorTimeoutMillis server-wide to work around one slow job: it makes every leaked cursor live longer. And do not hand-roll getMore loops in application code; drivers already do it, and you only need to choose a batch size and close cursors you abandon.
SecuritySecurity notes
Only the user who opened a cursor can run getMore or killCursors on it, so an id seen in a log cannot be used to read someone else's results. $currentOp with allUsers: true needs the inprog privilege (clusterMonitor), and it shows other users' filters, which can contain personal data. Grant it to monitoring roles only.
ChallengeTry it yourself
Open a cursor on reviews for rating: 5 with a batch size of 50. Fetch exactly two getMore batches of 50, then close the cursor with killCursors. Return an object with the sizes of the three batches and the number of cursors killed.
Show one solution
const r = db.runCommand({ find: "reviews", filter: { rating: 5 }, batchSize: 50 })
const b2 = db.runCommand({ getMore: r.cursor.id, collection: "reviews", batchSize: 50 })
const b3 = db.runCommand({ getMore: r.cursor.id, collection: "reviews", batchSize: 50 })
const k = db.runCommand({ killCursors: "reviews", cursors: [r.cursor.id] })
;({ first: r.cursor.firstBatch.length, second: b2.cursor.nextBatch.length, third: b3.cursor.nextBatch.length, killed: k.cursorsKilled.length })InterviewQuestions you should be able to answer
A batch job fails with CursorNotFound after running for a while. What is happening and how do you fix it?
The cursor was idle longer than cursorTimeoutMillis (10 minutes) between two getMore calls, usually because each batch takes long to process, or the session expired after 30 minutes idle. Fixes, in order of preference: process in short pages keyed on _id with a checkpoint so the job is resumable; reduce batchSize so each batch is processed quickly; or, if one cursor must be held, use noCursorTimeout together with explicit sessions refreshed with refreshSessions, and always close the cursor in a finally block.
How do batches work between a driver and the server?
The first reply to find or aggregate carries up to 101 documents (or batchSize) and a cursor id. While the id is non-zero, the driver sends getMore, which returns the next batch up to batchSize documents or 16 MiB. When results are exhausted the server returns id 0 and frees the cursor. If the client stops early, the driver sends killCursors. Larger batches mean fewer round trips but more memory per batch on both sides.
When would you use a tailable cursor instead of a change stream?
Rarely today. Tailable cursors only work on capped collections, have no resume token and lose their place when the writer laps them. Change streams work on any collection, database or deployment, survive failover with resume tokens and filter events with a pipeline. A tailable cursor still fits a small capped log or queue on one replica set where losing a position is acceptable, and it is how older tools tailed the oplog.
QuizCheck your understanding
5 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.