Pagination Strategies in MongoDB
Almost every application that lists data — search results, a feed, an admin table — needs to page through it rather than return everything at once. MongoDB gives you two fundamentally different ways to do this: offset-based pagination (skip/limit) and cursor-based (keyset) pagination. They look similar in a UI but behave very differently at scale, and picking the wrong one is one of the most common performance mistakes in production MongoDB apps.
Offset-Based Pagination
This is the "page 1, page 2, page 3" model most people reach for first: multiply the page number by the page size to get an offset, then skip() that many documents and limit() the rest.
Offset pagination
const PAGE_SIZE = 20
function getPage(pageNumber) {
const skip = (pageNumber - 1) * PAGE_SIZE
return db.products
.find({ category: 'electronics' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(PAGE_SIZE)
.toArray()
}
getPage(1) // documents 1–20
getPage(50) // documents 981–1000The appeal is obvious: it's simple, and it supports "jump to page 50" directly. The problem is what skip() actually does internally — MongoDB has no way to jump straight to document 981. It has to walk the index (or the collection) from the beginning and discard the first 980 matching documents before it can return anything. The further into the result set a user pages, the more work every single query does, even though it returns the same 20 documents each time.
Cursor-Based (Keyset) Pagination
Instead of an offset, cursor pagination remembers a value from the last document on the current page (usually _id, or a sort field plus _id as a tiebreaker) and asks for "the next N documents after this one." Because that's a direct range condition, MongoDB can use the index to jump straight there — the query cost stays flat no matter how deep you page.
Cursor pagination with _id
const PAGE_SIZE = 20
// First page — no cursor yet
function getFirstPage() {
return db.products
.find({ category: 'electronics' })
.sort({ _id: 1 })
.limit(PAGE_SIZE)
.toArray()
}
// Subsequent pages — pass the _id of the last document you saw
function getNextPage(lastSeenId) {
return db.products
.find({
category: 'electronics',
_id: { $gt: lastSeenId },
})
.sort({ _id: 1 })
.limit(PAGE_SIZE)
.toArray()
}
// Client flow:
// page 1 -> getFirstPage()
// remember lastDoc._id from the response
// page 2 -> getNextPage(lastDoc._id)This is why cursor pagination is often called "infinite scroll" pagination — it naturally fits a "load more" UI rather than numbered page links, because you only ever know how to get to the next page, not to page 5,000 directly.
Pagination on a Non-Unique Sort Field
Real feeds are rarely sorted by _id — they're sorted by something like createdAt, which is not unique. If two documents share the same timestamp, a naive cursor on createdAt alone can skip or repeat rows. The fix is a compound cursor: sort by the field you care about, then break ties with _id.
Compound cursor for a non-unique sort field
function getNextPage(lastCreatedAt, lastId) {
return db.posts
.find({
$or: [
{ createdAt: { $lt: lastCreatedAt } },
{ createdAt: lastCreatedAt, _id: { $lt: lastId } },
],
})
.sort({ createdAt: -1, _id: -1 })
.limit(PAGE_SIZE)
.toArray()
}{ createdAt: -1, _id: -1 } that matches the sort exactly — otherwise MongoDB falls back to an in-memory sort, which defeats the entire purpose of cursor pagination.Comparing the Two Approaches
Offset (skip/limit) | Cursor (keyset) | |
|---|---|---|
Performance at depth | Degrades linearly with offset | Constant — uses index range scan |
Jump to arbitrary page | Yes ("go to page 50") | No — only next/previous |
Stable under inserts/deletes | No — rows can shift between pages | Yes — cursor tracks a real document |
Best UI fit | Numbered page links | Infinite scroll / "Load more" |
Implementation complexity | Very simple | Slightly more — needs a stable sort key |
The Bucket Pattern for Extreme Scale
For very high-write collections (activity feeds, time-series-like data), some teams go further and pre-group documents into "buckets" of N items per document — e.g. one document holding a day's worth of events. Pagination then becomes "fetch bucket N," which is a single document read instead of a query over thousands of small documents. This is a more invasive schema decision and is usually only worth it once keyset pagination alone isn't enough.
Practical Guidance
Use offset (skip/limit) pagination for small collections or admin UIs where users genuinely need to jump to an arbitrary page number.
Use cursor pagination for anything user-facing at scale — feeds, search results, infinite scroll — where users only ever go "next."
Always pair your sort field with a unique tiebreaker (usually _id) so the cursor is unambiguous.
Make sure a compound index exists that matches your sort exactly — check with explain() that you see an IXSCAN, not a SORT stage.
Avoid exposing raw skip values to API clients if you can help it; encode the cursor as an opaque token instead so you can change the underlying strategy later without breaking clients.
nextCursor field (often a base64-encoded version of the last sort key values) instead of a page number — the client just passes it back on the next request without needing to know what it means.Example: Cursor Response Shape
A typical paginated API response
{
"data": [ /* 20 documents */ ],
"pageInfo": {
"hasNextPage": true,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTAxIiwiX2lkIjoiNjVm..."
}
}# Client requests page 2 by sending the cursor back: GET /api/posts?cursor=eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTAxIiwiX2lkIjoiNjVm...&limit=20