Back

Pagination Patterns in MongoDB

Pagination Patterns in MongoDB

You’re building a feed, a product list, or a search results page. The collection has grown to millions of documents, and suddenly your pagination queries take seconds instead of milliseconds. The culprit is almost always the same: you’re using skip and limit at scale.

This article covers the core MongoDB pagination patterns—offset-based and cursor/keyset-based—explaining when each works well and when it doesn’t. No framework-specific advice, just patterns that will remain valid as your data grows.

Key Takeaways

  • Offset pagination with skip() and limit() is intuitive but degrades linearly as offset values increase—reserve it for shallow paging or small datasets.
  • Keyset (cursor-based) pagination uses range queries for consistent performance regardless of position, making it ideal for large datasets.
  • Always use a unique tiebreaker field (typically _id) with keyset pagination to prevent duplicates or skipped documents.
  • Choose your pagination pattern based on data size, access patterns, and whether users need sequential navigation or arbitrary page jumps.

MongoDB Skip Limit Pagination: The Familiar Approach

Offset-based pagination uses skip() and limit() to divide results into pages. It’s intuitive and maps directly to “page 1, page 2, page 3” UI patterns.

db.posts.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * pageSize)
  .limit(pageSize)

This pattern works fine for shallow paging—the first few pages of results, admin dashboards with limited data, or internal tools where performance isn’t critical.

Why Skip Degrades at Scale

MongoDB indexes are B-tree structures, not arrays. While the database can efficiently seek into an index, it still has to advance through skipped entries to reach the requested offset. Skipping 10 documents advances past 10 index entries. Skipping 100,000 advances past 100,000 entries.

This means page 1 is fast, page 100 is slower, and page 10,000 is significantly slower still. CPU usage increases linearly with the offset value, regardless of your page size.

Use offset pagination when:

  • Users rarely navigate beyond the first few pages
  • The total dataset is small (under 10,000 documents)
  • You need “jump to page X” functionality and accept the tradeoff
  • Building internal tools where query time is less critical

MongoDB Cursor-Based Pagination: Consistent Performance

MongoDB keyset pagination (also called cursor-based pagination) uses range queries instead of positional offsets. Rather than saying “skip 1,000 documents,” you say “give me documents after this specific point.”

db.posts.find({ createdAt: { $lt: lastSeenDate } })
  .sort({ createdAt: -1 })
  .limit(pageSize)

The database performs an efficient index seek to locate the starting point, then reads only the documents you need. Page 1 and page 10,000 have identical performance characteristics.

The Tiebreaker Requirement

A single sort field creates problems when values aren’t unique. If multiple documents share the same createdAt timestamp, some may be skipped or duplicated across pages.

The solution is a compound sort with a unique tiebreaker—typically _id:

db.posts.find({
  $or: [
    { createdAt: { $lt: lastDate } },
    { createdAt: lastDate, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(pageSize)

This requires a matching compound index:

db.posts.createIndex({ createdAt: -1, _id: -1 })

The index field order must match your sort order for optimal performance.

Consistency Caveats

Cursor-based pagination is more stable than offset pagination, but it’s not magically consistent. If documents are inserted, deleted, or if mutable sort fields change between requests, users may still see duplicates or miss items. The difference is that keyset pagination anchors to specific values rather than positions, so concurrent writes typically cause fewer visible anomalies.

For feeds and lists where perfect consistency isn’t required, this tradeoff is usually acceptable.

Atlas Search Pagination: A Different Mechanism

If you’re using MongoDB Atlas Search with the $search stage, pagination works differently. Atlas Search uses its own token-based system—via searchSequenceToken under the hood—with searchAfter and searchBefore parameters rather than _id cursors. Don’t mix these approaches—use the pagination method that matches your query type.

Choosing the Right Pattern

ScenarioRecommended Pattern
Infinite scroll feedsKeyset pagination
”Load more” buttonsKeyset pagination
Admin tables with page numbersOffset (shallow pages only)
Large datasets with sequential navigationKeyset pagination
Small, static datasetsEither works

The decision often comes down to UI requirements. If users need to jump to arbitrary pages, you’re constrained to offset pagination or hybrid approaches. If sequential navigation suffices—next, previous, load more—keyset pagination scales better.

Conclusion

Offset pagination with skip and limit is simple but degrades linearly with offset size. Reserve it for shallow paging or small datasets.

Keyset pagination maintains consistent performance regardless of position but requires deterministic sorting with a unique tiebreaker. It’s the better choice for feeds, lists, and any interface where users navigate sequentially through large result sets.

Neither pattern is universally wrong. Choose based on your data size, access patterns, and UI requirements.

FAQs

Yes, you can use multiple sort fields with keyset pagination. The key requirement is that your final field must be unique to serve as a tiebreaker. Your compound index must match the exact order and direction of all sort fields. The query logic becomes more complex with each additional field, as you need nested OR conditions to handle equality cases for each preceding field.

For backward pagination, one common approach is to reverse your comparison operators and sort direction. If forward uses $lt with descending sort, backward uses $gt with ascending sort. Fetch the results, then reverse them in your application to maintain display order. Store both the first and last document cursors from each page to enable bidirectional navigation.

If a document's sort field changes while a user is paginating, they may see that document twice or miss it entirely depending on whether it moved forward or backward relative to their cursor position. For frequently updated fields, consider using immutable values like _id as your primary sort key, or accept this as an inherent limitation of real-time data.

Keyset pagination doesn't naturally support total counts because it doesn't track position. You can run a separate count query, but this adds overhead on large collections. Consider whether users truly need exact counts—often approximate counts or simply indicating more results exist provides sufficient context without the performance cost.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay