MongoDBPagination Strategies

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

JS
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–1000

The 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.

Warning
On a collection with a few thousand documents, offset pagination feels instant. On a collection with millions of documents, page 1 stays fast while page 5,000 can take seconds — the classic "pagination gets slower the deeper you go" symptom.
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

JS
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

JS
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()
}
Note
This requires a compound index on { 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.

Tip
A common REST API convention is to return a 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

JS
{
  "data": [ /* 20 documents */ ],
  "pageInfo": {
    "hasNextPage": true,
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTAxIiwiX2lkIjoiNjVm..."
  }
}
# Client requests page 2 by sending the cursor back:
GET /api/posts?cursor=eyJjcmVhdGVkQXQiOiIyMDI1LTAxLTAxIiwiX2lkIjoiNjVm...&limit=20