High latency observed when using Mongoose skip/limit for deep page numbers
0 reputation · 05 Dec 2020, 15:27 UTC
0 reputation · 05 Dec 2020, 15:27 UTC
Goal: Determine why query response time grows noticeably when requesting higher page numbers while paginating a large collection with Mongoose’s skip() and limit() methods.
Constraints: The collection contains millions of documents, the application must return results in a consistent order, and we cannot change the existing sort field without impacting other services. It is unclear whether the observed latency stems from the server‑side scan caused by large skip values or from client‑side memory usage when streaming results.
Specific questions:
docsExamined metric increase roughly as skip + limit for these queries, confirming a linear scan cost?skip/limit with a range query on an indexed monotonic field (e.g., _id) eliminate the scan overhead while preserving order?Query.cursor() to stream results, what batch size should be set to keep memory usage bounded without sacrificing throughput?When you paginate a collection that contains millions of documents using skip() and limit(), the latency grows roughly linearly with the page number. The driver sends a query that MongoDB must walk skip + limit documents before it can return the requested slice, even if the sort field is indexed. This is why docsExamined in executionStats matches skip + limit.
Replacing skip/limit with a range query on an indexed monotonic field (commonly _id) removes that linear scan. Because the index can be used to jump directly to the starting document, the query time stays constant regardless of page depth, while preserving the original sort order.
When using Query.cursor() to stream results, a batch size of about 1,000 documents keeps driver memory bounded (typically <200 MB) without sacrificing throughput. Batch sizes below 100 cause many round‑trips, and sizes above 10,000 can spike memory.
skip documents, even with an index on the sort field.// Example with Mongoose
const stats = await Model.find()
.skip(N)
.limit(M)
.explain('executionStats');
console.log(stats.executionStats.totalDocsExamined); // should ≈ N + M
_id:
// Determine start and end ObjectIds for the desired page
const startId = ...; // e.g., the _id of the last doc on the previous page
const endId = ...; // optional, can use $gt only
const rangeStats = await Model.find({ _id: { $gt: startId } })
.limit(M)
.explain('executionStats');
console.log(rangeStats.executionStats.totalDocsExamined); // should be close to M
_id is indexed and that the natural order matches your sort order.const cursor = Model.find().sort({ _id: 1 }).cursor().batchSize(1000);
for await (const doc of cursor) {
// process doc
}
Do you have an index on the field currently used for sorting (other than _id)? If not, creating that index is a prerequisite for the range‑query approach to be effective.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.