Mongoose skip() performance diagnostic: when to switch to cursor pagination
When Mongoose list endpoints slow down with deep skip() offsets, this diagnostic guide explains how to identify the cause, switch to cursor pagination, and use lean() for read‑only lists, with MongoShell verification steps.
21 Aug 2025, 16:59 UTC

Problem: Deep skip() offsets cause query latency
\nWhen a Mongoose list endpoint uses skip(n) with n greater than a few hundred, the underlying MongoDB query must scan and discard n documents before returning the requested page. This O(n) behavior becomes noticeable as collections grow, leading to seconds-long response times. Cursor pagination on an indexed field avoids full collection scans.
Useful takeaway
\nFor pages beyond the first few, replace limit(n).skip(m) with cursor-based pagination that filters on an indexed field, such as { _id: { $gt: lastId }}. Pair lean() with read-only lists to skip document hydration and reduce memory overhead.
Diagnostic condition
\nIntermittent slowdowns on list endpoints that use Mongoose .skip() to paginate through a growing collection. Response time increases roughly linearly with the skip value.
Cause and diagnostic table
\n| Symptom | Likely cause | Quick check |
|---|---|---|
| Response time grows as skip increases | MongoDB scans skip documents before returning limit | Run in MongoShell with .explain('executionStats'); expect examinedDocuments close to skip value. |
| High memory usage | Document hydration loads full objects | Add .lean() and measure per-document time. |
Ordered checks
\n- \n
- Verify the skip value in the request payload and Mongoose chain. Placeholder:
req.query.skiporparseInt(req.params.page). \n - Run the equivalent MongoShell query with
.explain('executionStats')to see the number of examined documents. Required permission: read access on the collection. Risk: large skip values may take seconds to minutes; use caution on production. \n - Check whether the cursor field (typically
_id) is indexed. Without an index, MongoDB performs a collection scan. Placeholder:db.collection.getIndexes(). \n - Try the same query with
.lean()appended and compare memory and time metrics. Note:lean()should only be used for read‑only queries; mixing with writes can bypass schema validation. \n - If the skip value exceeds 1,000 and the collection has millions of rows, plan a cursor migration. \n
Fixes tied to findings
\nReplace deep skip with cursor pagination: Instead of Model.find().skip(5000).limit(10), use Model.find({ _id: { $gt: lastId } }).limit(10).sort({ _id: 1 }). Because _id is indexed by default, MongoDB jumps directly to the first document after the last seen identifier.
// Mongoose cursor pagination example\nconst lastId = await Model.findOne().sort({ _id: -1 }).select('_id').lean();\nconst page = await Model.find({ _id: { $gt: lastId } }).limit(10).sort({ _id: 1 }).lean();\n\nAdd .lean() for read-only lists: When only field values are needed, .lean() returns plain JavaScript objects instead of Mongoose documents, reducing per-document overhead by 30–70 % in benchmarks. Use only for read operations.
// Mongoose lean query\nconst users = await User.find({ active: true }).lean().limit(20);\n\nLimitations and practical verification
\nCursor pagination requires a stable, indexed sort key. If documents are frequently inserted without guaranteed _id ordering, or if the application needs arbitrary offset-based UI (e.g., "page 5 of 10"), cursor pagination may not match the expected UI behavior. To verify the improvement, run the following in MongoShell:
// Run in mongosh with read permission on the target collection.\n// Risk: skip(n) with large n may take seconds to minutes; interrupt with Ctrl+C if needed.\ndb.users.find({ active: true }).limit(20).skip(5000).explain('executionStats')\ndb.users.find({ active: true, _id: { $gt: ObjectId('653c0d9c9a1d4e001c8a8b2a') } ).limit(20).explain('executionStats')\n\nThe first command will show examined documents approaching 5,020. The second will show examined documents close to 20, confirming the index jump. Compare the executionStats.executionTimeMillis values to quantify the gain.
Escalation criteria
\n- \n
- The cursor field is not indexed or cannot be indexed without a schema change. \n
- The application requires exact offset-based pagination (e.g., jump to page 7) and cannot adapt to cursor semantics. \n
- Performance remains above acceptable thresholds after cursor migration and
.lean()adoption. \n
At that point, consider schema redesign, sharding keys, or a dedicated pagination service. Document the current skip pattern, benchmark results, and the chosen approach for future maintainers.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.