Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 022 / 100

Pagination in MongoDB: a practical MongoDB & NoSQL guide

Skip is easy and slow. Ranges are fast.

Expona Admin6 min read

Pagination in MongoDB: a practical MongoDB & NoSQL guide

Pagination is one of the most deceptive problems in database work. It feels trivial until you're deep in production with 50 million documents and a single user loading page 500, which suddenly locks your database for seconds. Pagination in MongoDB: a practical MongoDB & NoSQL guide approaches this challenge with two fundamentally different strategies: the naive skip-and-limit pattern that most developers start with, and the far more scalable cursor-based range pagination that wins in production.

We'll walk through both approaches, explain why one fails at scale and why the other thrives, and give you the patterns you'll actually use in your next service—whether it's an e-commerce product list, a feed, or a reporting dashboard.

Skip and Limit — Easy, Familiar, and Slow

Most developers reach for skip and limit first because the MongoDB syntax is straightforward:

javascript
db.posts.find({})
  .sort({ createdAt: -1 })
  .skip(20)
  .limit(20);

This feels natural. Page 1 skips 0, page 2 skips 20, page 3 skips 40. In real-world scenarios—a blog, a product catalog, a customer list—you probably shipped this pattern and it worked fine.

The problem emerges when someone asks for page 50 (skip 1000) or page 500 (skip 10,000). MongoDB has to scan and discard those first 10,000 documents even if they're not in memory. On a collection with 100 million documents, skipping 50,000 becomes a table scan. Your query plan shows a COLLSCAN, your CPU spikes, and concurrent queries start queuing.

In a real production e-commerce service, we watched skip-based pagination degrade from 2ms at page 1 to 800ms at page 50. That's not because the database was underpowered—it was because we were throwing away work.

When is skip acceptable? When you're confident users won't go deep (like, genuinely shallow—first 100 items), or when result sets are under 10,000 documents. If your product spec says "load more" instead of numbered pages, or your UI shows only 3-5 pages of results, skip works. Otherwise, plan for something better.

Range Pagination — Cursors Over Offsets

Range pagination inverts the problem. Instead of asking "skip me 500 documents and give me the next 20," you ask "give me 20 documents after this one."

The pattern uses the last value from the previous result as a cursor:

javascript
// First request
const firstPage = await db.posts.find({})
  .sort({ _id: -1 })
  .limit(20)
  .toArray();

// Response includes the last _id as cursor
const lastPost = firstPage[firstPage.length - 1];
const nextCursor = lastPost._id;

// Second request uses the cursor
const secondPage = await db.posts.find({ _id: { $lt: nextCursor } })
  .sort({ _id: -1 })
  .limit(20)
  .toArray();

This query has a critical advantage: MongoDB can use an index on _id to jump directly to the range you need. No scanning 10,000 documents. Even on 100 million documents, the performance is consistent because you're always doing an indexed range query.

In the same production service, switching to range-based cursors brought page 50 down from 800ms to 3ms. The latency became flat. That's the win.

How it works in practice: Your API returns the cursor alongside results. The client sends it back with the next request. No page numbers, no offsets—just "show me what comes after this." Slack, Twitter, and most modern APIs work this way.

Stable Sort — The Hidden Gotcha

Here's where teams stumble. You write this and call it done:

javascript
// Risky: not stable
db.posts.find({ _id: { $lt: lastId } })
  .sort({ createdAt: -1 })
  .limit(20);

The problem: if two posts have the same createdAt timestamp (it happens—milliseconds round, or you insert in bulk), MongoDB has no deterministic order between them. The first page might include post A then B, but the second page might include B then A again. You get duplicates or missing results.

The fix is to always add a unique secondary sort:

javascript
// Stable: _id breaks ties
db.posts.find({ _id: { $lt: lastId } })
  .sort({ createdAt: -1, _id: -1 })
  .limit(20);

Now if two posts share a createdAt, we order them by _id. Since _id is unique, the order is always the same. And because your cursor is also the _id, the range query stays clean.

In real-world event logging or feed services, this matters. We've seen duplicates creep into customer-facing results because someone sorted only by timestamp and the system inserted 50 records in the same millisecond.

Returning Cursors, Not Page Numbers

Once you move to range pagination, page numbers don't make sense anymore. You can't say "jump to page 7" without knowing every document that came before it. Instead, you return a cursor—usually just the last document's sort key—and let the client request the next batch.

A typical API response looks like this:

javascript
{
  "data": [
    { "_id": ObjectId(...), "title": "Post 1", "createdAt": "2024-01-15T10:30:00Z" },
    { "_id": ObjectId(...), "title": "Post 2", "createdAt": "2024-01-15T10:25:00Z" },
    ...
  ],
  "nextCursor": "ObjectId(...)",
  "hasMore": true
}

The client stores nextCursor and passes it in the next request. You encode it however you like—base64 for obfuscation, JSON for clarity, a simple ID string if you trust the client.

A simple NestJS example:

javascript
async getPosts(cursor?: string, limit: number = 20) {
  const query: any = {};
  
  if (cursor) {
    query._id = { $lt: new ObjectId(cursor) };
  }
  
  const posts = await this.db.posts
    .find(query)
    .sort({ createdAt: -1, _id: -1 })
    .limit(limit + 1) // Fetch one extra to check if there are more
    .toArray();
  
  const hasMore = posts.length > limit;
  const data = posts.slice(0, limit);
  const nextCursor = data.length > 0 ? data[data.length - 1]._id : null;
  
  return { data, nextCursor, hasMore };
}

This pattern scales indefinitely. A user can request page 1 million with the same 3ms latency as page 1, because you're always doing the same index-backed range query.

Common Mistakes

  • Sorting by only one field: Two documents with the same sort value create duplicates or missing results across pages. Always add a unique tiebreaker (usually _id).
  • Including the cursor in the next range: If your last document has _id: A, use { $lt: A } not { $lte: A }, or you'll include it twice.
  • Trusting skip on large datasets: Team members will suggest "skip is fine for us, we only go to page 10." Then a power user or API scraper hits page 1000 and production melts. Set guard rails early.
  • Forgetting to index the cursor field: If you're paginating on createdAt, add a compound index: db.posts.createIndex({ createdAt: -1, _id: -1 }). Without it, you lose the speed gain.
  • Mutating sort order mid-pagination: If the user changes from "newest first" to "oldest first" mid-scroll, their cursor is now meaningless. Reset to page 1 or handle the sort switch explicitly.

Key Takeaways

  • Skip-and-limit works for small, confident use cases; it degrades predictably as you go deeper.
  • Range pagination using indexed cursors is O(1) regardless of position, making it the production choice.
  • Always include a unique secondary sort field to ensure stable order across pages.
  • Encode the cursor (usually the last document's _id) and return it to the client; they send it back with the next request.
  • Create a compound index on your sort fields (e.g., createdAt, _id) to make cursor queries instant.
  • Consider fetching limit + 1 to determine if more results exist, avoiding a separate count query.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles