How does MongoDB pagination work and why is skip/limit inefficient at scale?
Learn why MongoDB skip/limit pagination slows on deep pages and how keyset (range) pagination delivers constant-time page loads at scale.
Expected Interview Answer
MongoDB pagination usually uses skip() and limit() to return one page of documents, but skip() is inefficient at scale because the server must still walk and discard every skipped document before returning the page, so deep pages get progressively slower.
With skip(n).limit(m), MongoDB scans and throws away the first n matching documents on every request, making cost grow linearly with page depth. The scalable alternative is range-based (keyset) pagination: sort by an indexed field such as _id or a timestamp, and on each request fetch the next page using a filter like { _id: { $gt: lastSeenId } } with limit(m). This uses the index to jump directly to the boundary, giving constant-time page fetches regardless of depth.
- Keyset pagination keeps latency constant at any page depth
- Uses an index seek instead of scanning skipped documents
- Avoids duplicate or missing rows when data changes between pages
- Reduces server CPU and memory pressure on deep pages
- Scales predictably for infinite-scroll and large result sets
AI Mentor Explanation
To find the 10,000th ball of a long innings, skip/limit is like replaying the whole match commentary from the first delivery, silently ignoring every ball until you reach number 10,000 — exhausting and slow. Keyset pagination is like knowing the last ball you watched was over 41.2 and jumping straight to over 41.3 in the ball-by-ball log, so you never re-watch what you already saw.
Step-by-Step Explanation
Step 1
Understand the naive approach
db.coll.find(query).sort({_id:1}).skip((page-1)*size).limit(size) returns a page but re-scans all skipped documents each call.
Step 2
Measure the cost
Run explain() and note that totalDocsExamined grows with the skip value while limit stays fixed — the deeper the page, the more waste.
Step 3
Pick a stable sort key
Choose an indexed, unique, monotonic field (often _id or a compound of timestamp + _id) to define a strict ordering.
Step 4
Track the boundary cursor
Return the last document's sort value to the client and use it as the starting point for the next request.
Step 5
Query by range
Fetch the next page with find({ _id: { $gt: lastId } }).sort({_id:1}).limit(size), which seeks the index directly to the boundary.
Step 6
Ensure an index covers the sort
Confirm the sort/filter fields are backed by an index so the query is an efficient index range scan, not an in-memory sort.
What Interviewer Expects
- Knowing skip() still scans skipped documents server-side
- Explaining that cost grows linearly with page depth
- Describing keyset/range pagination as the scalable fix
- Choosing a unique, indexed, monotonic sort key
- Awareness of consistency issues when data changes between pages
Common Mistakes
- Believing skip() jumps directly to the offset without scanning
- Using a non-unique sort field causing skipped or duplicated rows
- Sorting on an unindexed field, forcing an in-memory sort
- Returning total page counts that require expensive full counts
- Assuming keyset pagination allows random jumps to arbitrary pages
Best Answer (HR Friendly)
“MongoDB normally shows results a page at a time using skip and limit, but on deep pages it still has to walk past everything it skips, so it gets slow. A faster approach remembers the last item you saw and asks only for items after it, so each page loads quickly no matter how far you scroll.”
Code Example
const pageSize = 20;
const page = 500; // deep page
const results = await db.collection('orders')
.find({ status: 'shipped' })
.sort({ _id: 1 })
.skip((page - 1) * pageSize) // scans 9,980 docs just to discard them
.limit(pageSize)
.toArray();const pageSize = 20;
// lastId comes from the last document of the previous page
async function nextPage(lastId) {
const filter = { status: 'shipped' };
if (lastId) filter._id = { $gt: lastId };
const results = await db.collection('orders')
.find(filter)
.sort({ _id: 1 })
.limit(pageSize)
.toArray();
const nextCursor = results.length ? results[results.length - 1]._id : null;
return { results, nextCursor };
}
// Index that backs both the filter and the sort:
// db.orders.createIndex({ status: 1, _id: 1 })Follow-up Questions
- How would you paginate by a non-unique field like createdAt?
- Why can keyset pagination not jump directly to an arbitrary page number?
- How does explain() reveal the cost of a deep skip?
- What compound index would you create for range pagination?
- How do cursor-based APIs (like GraphQL Relay) map onto keyset pagination?
MCQ Practice
1. Why is skip(n).limit(m) inefficient for deep pages in MongoDB?
skip() does not seek directly to the offset; MongoDB must walk past every skipped matching document before returning the page, so cost grows with depth.
2. What is the key requirement for a reliable keyset pagination sort field?
A unique, indexed, monotonic field lets the range filter seek the boundary precisely and avoids skipped or duplicated documents between pages.
3. Which query pattern implements keyset pagination?
Filtering by the last seen _id and limiting the result lets the index jump straight to the next page in constant time.
Flash Cards
Does skip() jump directly to the offset? — No. MongoDB scans and discards every skipped document first, so deep pages get slower.
What is keyset pagination? — Fetching the next page with a range filter on the last seen sort value (e.g. _id > lastId) plus limit(), backed by an index.
Best sort key for keyset pagination? — A unique, indexed, monotonic field such as _id, or a compound of timestamp + _id to break ties.
Trade-off of keyset pagination? — You get constant-time next/prev pages but cannot jump directly to an arbitrary page number.
How to spot a costly skip? — explain() shows totalDocsExamined rising with the skip value while nReturned stays fixed.