7.3 Cursor Batching, Streaming & Memory Handling
Key Takeaways
- MongoDB query execution is lazy: 'collection.find()' creates a client-side cursor pointer without transmitting documents until iteration begins.
- The server returns an initial batch limited to 101 documents or 1 MB of BSON data, followed by subsequent batches of up to 16 MiB retrieved via 'getMore' commands.
- The 'batchSize(n)' cursor method allows developers to tune document counts per batch, balancing network round-trips against application heap memory.
- Server cursors time out after 10 minutes of inactivity; 'noCursorTimeout: true' disables this but requires explicit 'cursor.close()' in a finally block to prevent server memory leaks.
- Invoking 'cursor.toArray()' buffers the entire result set into application RAM, risking heap out-of-memory crashes; streaming or async iteration ('for await') must be used for large datasets.
7.3 Cursor Batching, Streaming & Memory Handling
When an application queries MongoDB for hundreds of thousands or millions of documents, transferring the entire dataset in a single monolithic payload would overwhelm network buffers, saturate client memory, and cause catastrophic application crashes. To solve this problem, MongoDB implements Cursors, Network Batching Protocols, and Server-Side Cursor Management.
A clear understanding of how cursors work, how the getMore wire protocol operates, and how to stream large datasets without triggering memory exhaustion is a core competency tested on the MongoDB Associate Developer Exam.
1. Cursor Architecture & Lazy Evaluation
A Cursor is not an in-memory array of documents. Rather, it is an active pointer to a result set residing on the database server (mongod or mongos).
Lazy Evaluation Mechanics
When a developer executes a find() query in an application driver:
// In Node.js / PyMongo / Java:
const cursor = db.collection("orders").find({ status: "COMPLETED" });
At the moment this line executes, no database query has been sent over the wire, and no documents have been transferred. The driver simply instantiates a local cursor object with the query specifications. Execution is deferred until the application attempts to read the first document (e.g., via await cursor.next(), for await (const doc of cursor), or cursor.stream()).
+---------------------------------------------------------------------------------------------------+
| MongoDB Cursor Batching Protocol |
| |
| 1. INITIAL QUERY COMMAND |
| Client ====================== find({ status: "COMPLETED" }) ======================> Server |
| Client <================ { cursor: { id: 8941029412L, firstBatch: [...] } } <======= Server |
| * First Batch Limit: 101 documents OR 1 MB of BSON data (whichever limit is hit first). |
| |
| 2. SUBSEQUENT BATCH RETRIEVAL (getMore) |
| Client ============== getMore: 8941029412L, collection: "orders" ==================> Server |
| Client <==================== { cursor: { nextBatch: [...] } } <====================== Server |
| * Subsequent Batch Limit: Up to 16 MiB of BSON data (or configured batchSize). |
| |
| 3. CURSOR CLOSURE |
| Client ============== getMore: 8941029412L, collection: "orders" ==================> Server |
| Client <==================== { cursor: { id: 0L, nextBatch: [...] } } <============== Server |
| * Server returns cursorId = 0, indicating all documents drained; server frees cursor state. |
+---------------------------------------------------------------------------------------------------+
The Batching Protocol: Initial Batch vs. getMore
MongoDB divides query results into discrete batches transmitted over the network:
-
The Initial Batch (
findcommand):- The server evaluates the query and returns the First Batch (
firstBatch). - Batch Threshold: Contains a maximum of 101 documents or 1 Megabyte (1 MB) of BSON data, whichever limit is reached first.
- If the entire query result set fits within this initial limit, the server returns
cursorId: 0immediately, closing the cursor in a single network round-trip. - If more matching documents remain, the server assigns a non-zero 64-bit integer
cursorIdand pins the cursor state in server RAM.
- The server evaluates the query and returns the First Batch (
-
Subsequent Batches (
getMorecommand):- When the client application iterates past the last document in its local buffer, the driver transparently sends a
getMorecommand with thecursorId. - Batch Threshold: Subsequent batches contain up to 16 mebibytes (16 MiB) of BSON data — the BSON maximum message size — or the limit specified by
batchSize().batchSize()can only lower this ceiling, never raise it. - This process repeats until the query is exhausted.
- When the client application iterates past the last document in its local buffer, the driver transparently sends a
-
Cursor Termination (
cursorId: 0):- Once the last document has been transmitted, the server returns
cursorId: 0, and the database engine immediately releases all server-side memory, locks, and resources allocated to the cursor.
- Once the last document has been transmitted, the server returns
2. Cursor Configuration & Timeout Rules
Tuning Batch Size with batchSize()
Developers can override the default batch size using the .batchSize() cursor method:
// Override batch size to 500 documents per network round-trip
const cursor = db.collection("telemetry")
.find({ device_type: "SENSOR_V2" })
.batchSize(500);
Architectural Tradeoffs of batchSize():
- Smaller Batch Size (e.g.,
batchSize(20)): Reduces memory consumption on the client and server. Useful when documents are large (several megabytes each) or when processing each document is slow, reducing wasted data transfer if the consumer aborts early. - Larger Batch Size (e.g.,
batchSize(1000)): Maximizes network throughput by minimizing the number ofgetMoreround-trips when processing millions of small documents in bulk ETL pipelines.
The 10-Minute Cursor Idle Timeout
By default, the MongoDB server automatically closes active cursors that have been idle for more than 10 minutes (600,000 ms).
Why Idle Timeouts Occur:
If an application retrieves a batch of 100 documents and performs slow, blocking processing on each item (e.g., sending an email or calling a third-party HTTP API for 10 seconds per item), processing the batch takes 100 * 10 = 1,000 seconds (~16.6 minutes). When the driver finally attempts to fetch the next batch with getMore, the server has already deleted the idle cursor, throwing a CursorNotFound exception (Error Code 43).
Disabling Timeouts with noCursorTimeout: true
For long-running batch jobs, developers can disable the 10-minute timeout:
// Node.js: Disabling cursor timeout
const cursor = db.collection("large_dataset")
.find({}, { noCursorTimeout: true })
.batchSize(50);
CRITICAL PRODUCTION WARNING (Memory Leak Risk): When
noCursorTimeout: trueis set, the MongoDB server will never automatically close the cursor. If the client application crashes, encounters an unhandled exception, or exits the loop early without draining all documents, the cursor remains permanently open onmongod, consuming server RAM and locks until the database process is restarted.Mandatory Rule: When using
noCursorTimeout: true, you MUST wrap iteration in atry...finallyblock and explicitly invokecursor.close()in thefinallyclause.
// Correct Pattern for noCursorTimeout in Node.js
const cursor = db.collection("large_dataset").find({}, { noCursorTimeout: true });
try {
for await (const doc of cursor) {
await processHeavyDocument(doc);
}
} finally {
// Guarantees cursor is closed on mongod even if processing throws an error!
await cursor.close();
}
# Correct Pattern for no_cursor_timeout in Python (PyMongo)
cursor = db.large_dataset.find({}, no_cursor_timeout=True).batch_size(100)
try:
for doc in cursor:
process_heavy_task(doc)
finally:
cursor.close() # Mandatory cleanup
3. Iteration Paradigms & The toArray() Memory Trap
How an application iterates over a cursor fundamentally impacts memory consumption and garbage collection behavior.
The toArray() Anti-Pattern
// DANGEROUS ANTI-PATTERN: toArray() on large result sets
const allOrders = await db.collection("orders").find({}).toArray(); // 1,000,000 documents!
res.json(allOrders);
Why toArray() Causes Production Outages:
toArray()forces the driver to immediately fetch every single matching document across multiplegetMorerequests and allocate all documents into a single contiguous JavaScript array in heap memory.- If the query matches 500,000 documents of 2 KB each,
toArray()attempts to allocate over 1 GB of heap memory simultaneously. In Node.js (which defaults to a 2–4 GB heap limit), this immediately triggers aFATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memorycrash. - In Python, this causes high memory bloat and
MemoryError; in Java, it triggersjava.lang.OutOfMemoryError: Java heap space.
Recommended Iteration Patterns
| Iteration Method | Execution Behavior | Memory Profile | Recommended Use Case |
|---|---|---|---|
toArray() | Fetches and buffers all documents into an in-memory array | High ($O(N)$ memory proportional to total result set) | Small, strictly bounded queries ($N \le 100$) |
Async Iterator (for await) | Pulls documents one-by-one from local batch; fetches next batch on demand | Minimal ($O(1)$ memory; only current batch in RAM) | Standard record-by-record business processing |
Readable Stream (.stream()) | Emits data events with full backpressure handling | Minimal ($O(1)$ constant memory) | Exporting large datasets to CSV, S3, or HTTP responses |
forEach() cursor method | Iterates over documents sequentially using callback | Minimal ($O(1)$ constant memory) | Functional processing without building in-memory lists |
Stream-Based Processing Example (Node.js)
Using Node.js streams allows piping millions of documents from MongoDB directly to an HTTP response or disk file with a near-zero memory footprint:
import { pipeline } from "stream/promises";
import { Transform } from "stream";
app.get("/api/export-orders", async (req, res) => {
const cursor = db.collection("orders")
.find({ year: 2026 })
.batchSize(1000);
// Transform BSON document stream to JSON lines
const jsonLineTransformer = new Transform({
objectMode: true,
transform(doc, encoding, callback) {
callback(null, JSON.stringify(doc) + "\n");
}
});
res.setHeader("Content-Type", "application/x-ndjson");
res.setHeader("Content-Disposition", 'attachment; filename="orders.ndjson"');
try {
// Handles backpressure automatically: pauses cursor when client network is slow
await pipeline(cursor.stream(), jsonLineTransformer, res);
} catch (err) {
console.error("Streaming pipeline failed:", err);
}
});
When an application executes a standard find() query without custom batch size modifiers, what are the exact threshold limits governing the initial batch returned by the MongoDB server?
A developer writes a nightly batch ETL script that queries 2,000,000 audit log records using '{ noCursorTimeout: true }'. Halfway through execution, the script encounters an unhandled JSON parsing exception and crashes. What is the operational impact on the MongoDB server cluster?
A microservice endpoint exports user activity logs. The developer writes 'const logs = await db.collection("logs").find({}).toArray()'. Under high load, the Node.js process crashes with 'JavaScript heap out of memory'. What is the root cause of this failure?
What is the maximum default BSON data payload size returned by the MongoDB server for subsequent batches requested via the getMore wire protocol command?