Type something to search...
How to Implement Pagination in MongoDB: Skip/Limit vs. Range-Based

How to Implement Pagination in MongoDB: Skip/Limit vs. Range-Based

Almost every application that lists things eventually needs pagination. A product catalog, an activity feed, an admin table of orders: none of them can return a million documents in one response. The obvious first attempt in MongoDB is skip() plus limit(), and it works perfectly in development, where the collection has 200 documents and every page loads instantly.

Then the collection grows. Page 1 is still fast, but page 5,000 takes seconds, and users scrolling a feed start seeing the same item twice or missing items entirely. The problem isn't MongoDB being slow. It's that offset-based pagination asks the database to do work that grows with the page number, and it describes a "page" in a way that shifts whenever data changes.

This guide covers both approaches in depth: how offset pagination with skip() and limit() works and where it breaks, how to build range-based pagination (also called keyset or cursor pagination) with correct tie-breaking and indexes, how to handle total counts, and how to choose between the two for a given screen.

The Two Approaches at a Glance

Before diving into code, here's the high-level comparison:

ConcernSkip/LimitRange-Based
Jump to page NYes, triviallyNo, only next/previous
Cost of deep pagesGrows linearly with the offsetConstant, regardless of depth
Stable under insertsNo, items shift between pagesYes, the cursor anchors to a real value
Implementation effortMinimalModerate (cursor encoding, tie-breakers)
Works with arbitrary sortsYesNeeds a unique, indexed sort key combination
Typical UINumbered pages in admin tablesInfinite scroll, "Load more", APIs

Neither is universally better. The rest of this article is about understanding the trade-offs well enough to pick deliberately.

Offset Pagination with skip() and limit()

Offset pagination translates a page number into a number of documents to skip. With a page size of 20, page 1 skips 0, page 2 skips 20, page 3 skips 40, and so on.

const pageSize = 20;
const page = 3;

db.products
  .find({ category: "lighting" })
  .sort({ createdAt: -1, _id: -1 })
  .skip((page - 1) * pageSize)
  .limit(pageSize);

In the Node.js driver, the shape is identical:

async function getProductsPage(db, { category, page = 1, pageSize = 20 }) {
  const safePage = Math.max(1, Number.parseInt(page, 10) || 1);
  const safeSize = Math.min(
    100,
    Math.max(1, Number.parseInt(pageSize, 10) || 20),
  );

  return db
    .collection("products")
    .find({ category })
    .sort({ createdAt: -1, _id: -1 })
    .skip((safePage - 1) * safeSize)
    .limit(safeSize)
    .toArray();
}

And with PyMongo:

def get_products_page(db, category, page=1, page_size=20):
    page = max(1, int(page))
    page_size = min(100, max(1, int(page_size)))

    cursor = (
        db.products.find({"category": category})
        .sort([("createdAt", -1), ("_id", -1)])
        .skip((page - 1) * page_size)
        .limit(page_size)
    )
    return list(cursor)

Notice two details that matter regardless of which approach you choose. First, the page size is clamped. Never let a client request pageSize=1000000. Second, the sort includes _id as a final key. Without a deterministic sort, MongoDB is free to return documents with equal createdAt values in any order, and the same document can appear on two different pages.

Why skip() Gets Slower on Deep Pages

skip() doesn't jump to an offset the way an array index does. The server has to find and walk past every skipped document (or index entry) before it can return the ones you asked for. Page 1 examines 20 entries. Page 5,000 examines 100,000.

You can see this directly with explain(). Assuming an index on { category: 1, createdAt: -1, _id: -1 }:

db.products
  .find({ category: "lighting" })
  .sort({ createdAt: -1, _id: -1 })
  .skip(100000)
  .limit(20)
  .explain("executionStats").executionStats;
{
  "nReturned": 20,
  "totalKeysExamined": 100020,
  "totalDocsExamined": 100020,
  "executionTimeMillis": 184
}

Twenty documents returned, over 100,000 keys and documents examined. The index keeps the sort cheap, but it can't make skipping free. If you're new to reading this output, the post on using explain() to analyze slow queries walks through every field.

Without a supporting index, it gets much worse: MongoDB must fetch every matching document, sort them in memory, and then discard the skipped ones. Large in-memory sorts are expensive, and in recent versions they spill to disk rather than failing, which keeps the query alive but makes it slower still.

Why Pages Drift

The second problem is correctness. Offsets describe "the 41st through 60th documents right now." If a new product is inserted at the top while a user is on page 2, everything shifts down by one. When they click to page 3, the last item from page 2 appears again as the first item on page 3. Deletions do the opposite and silently skip an item.

For an admin table that someone glances at, that's tolerable. For an infinite-scroll feed or an API that a sync job walks page by page, it's a real bug: duplicate rows in the UI, or records that never get processed.

Range-Based Pagination

Range-based pagination replaces "skip N documents" with "give me documents that come after this one." The client sends back a value from the last item it saw, and the query uses it as a lower (or upper) bound. Because the bound is an indexed range condition, MongoDB seeks directly to the right place in the index and reads only the documents it returns.

The Simplest Version: Paginating by _id

If you're happy ordering by insertion time, the default ObjectId is a convenient key. It's unique, indexed automatically, and roughly increasing over time.

// First page
const firstPage = db.events.find().sort({ _id: -1 }).limit(20).toArray();
const lastId = firstPage[firstPage.length - 1]._id;

// Next page
db.events
  .find({ _id: { $lt: lastId } })
  .sort({ _id: -1 })
  .limit(20);

Run explain() on the second query and you'll see totalKeysExamined: 20 whether you're on page 2 or page 50,000. That constant cost is the whole point.

One caveat: ObjectId values are only roughly time-ordered. They start with a seconds-resolution timestamp, and values generated in the same second on different machines don't sort in strict creation order. That's fine for pagination (you just need a stable, unique order), but don't treat _id order as an exact timeline. The article on ObjectId structure and timestamps goes deeper.

Paginating by a Non-Unique Field

Real screens usually sort by something meaningful: newest first by createdAt, cheapest first by price, highest score first. Those fields aren't unique. Two posts can share a timestamp, and hundreds of products can cost 19.99.

If you paginate only on createdAt, using $lt: lastCreatedAt skips every other document that shares the boundary value, while $lte repeats them. The fix is a tie-breaker: sort by the meaningful field and then by _id, and make the range condition compare the pair.

db.posts
  .find({
    status: "published",
    $or: [
      { createdAt: { $lt: lastCreatedAt } },
      { createdAt: lastCreatedAt, _id: { $lt: lastId } },
    ],
  })
  .sort({ createdAt: -1, _id: -1 })
  .limit(20);

Read the $or as: "strictly older than the last item, or the same timestamp but a smaller _id." Together, the sort and the filter define a total order where every document has exactly one position.

The Index That Makes It Fast

Range-based pagination only delivers constant-time pages if an index matches the filter and the sort. Follow the equality, sort, range rule: equality fields first, then the sort fields in the same order and direction.

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

With this index, the planner can use the status equality to narrow to one section of the index, then walk it in createdAt, _id order starting from the boundary. Check with explain() that the winning plan is an IXSCAN with no SORT stage, and that totalKeysExamined stays close to your page size.

If your UI lets users choose between several sort orders, each one needs its own index. That's a good reason to offer a handful of sensible sorts rather than letting clients sort on any field.

A Complete Cursor Implementation in Node.js

Exposing raw createdAt and _id values in your API works, but it couples clients to your schema. A cleaner pattern is an opaque cursor: encode the boundary values into a string the client passes back without interpreting.

import { ObjectId } from "mongodb";

function encodeCursor(doc) {
  const payload = { c: doc.createdAt.toISOString(), id: doc._id.toHexString() };
  return Buffer.from(JSON.stringify(payload)).toString("base64url");
}

function decodeCursor(token) {
  try {
    const { c, id } = JSON.parse(
      Buffer.from(token, "base64url").toString("utf8"),
    );
    const createdAt = new Date(c);
    if (Number.isNaN(createdAt.getTime()) || !ObjectId.isValid(id)) return null;
    return { createdAt, _id: new ObjectId(id) };
  } catch {
    return null;
  }
}

export async function listPosts(db, { limit = 20, after } = {}) {
  const pageSize = Math.min(100, Math.max(1, Number(limit) || 20));
  const filter = { status: "published" };

  if (after) {
    const cursor = decodeCursor(after);
    if (!cursor) throw new Error("Invalid cursor");
    filter.$or = [
      { createdAt: { $lt: cursor.createdAt } },
      { createdAt: cursor.createdAt, _id: { $lt: cursor._id } },
    ];
  }

  // Fetch one extra document to know whether another page exists.
  const docs = await db
    .collection("posts")
    .find(filter, { projection: { title: 1, slug: 1, createdAt: 1 } })
    .sort({ createdAt: -1, _id: -1 })
    .limit(pageSize + 1)
    .toArray();

  const hasNextPage = docs.length > pageSize;
  const items = hasNextPage ? docs.slice(0, pageSize) : docs;

  return {
    items,
    nextCursor: hasNextPage ? encodeCursor(items[items.length - 1]) : null,
  };
}

A response from an endpoint built on this looks like:

{
  "items": [
    {
      "_id": "66f1c2...",
      "title": "Shipping v2",
      "slug": "shipping-v2",
      "createdAt": "2026-09-22T14:03:11.000Z"
    }
  ],
  "nextCursor": "eyJjIjoiMjAyNi0wOS0yMlQxNDowMzoxMS4wMDBaIiwiaWQiOiI2NmYxYzIuLi4ifQ"
}

Three details are worth copying. The limit + 1 trick tells you whether there's a next page without running a separate count. The cursor is validated before use, so a tampered token returns a clean error instead of a 500. And the projection keeps list responses small; you rarely need full documents in a list view.

Base64 is encoding, not encryption. If a cursor must not be forged or read (for example, it includes a tenant ID), sign it with an HMAC or keep the boundary server-side.

Going Backwards

"Previous page" is the same query in reverse. Flip the comparison operators and the sort direction, then reverse the results so they display in the original order:

async function listPostsBefore(db, cursor, pageSize = 20) {
  const docs = await db
    .collection("posts")
    .find({
      status: "published",
      $or: [
        { createdAt: { $gt: cursor.createdAt } },
        { createdAt: cursor.createdAt, _id: { $gt: cursor._id } },
      ],
    })
    .sort({ createdAt: 1, _id: 1 })
    .limit(pageSize)
    .toArray();

  return docs.reverse();
}

The same index serves both directions, because MongoDB can walk an index forwards or backwards.

Getting a Total Count

Numbered pagination UIs often show "Page 3 of 412" or "8,231 results." That number isn't free.

  • countDocuments(filter) returns an exact count for a filter. It runs an aggregation under the hood, and its cost grows with the number of matching documents, even with a good index.
  • estimatedDocumentCount() reads collection metadata and returns almost instantly, but it ignores filters and can be slightly off after unclean shutdowns. It's only useful for "total rows in this collection."
  • The legacy cursor.count() is deprecated. Don't use it in new code.

For filtered lists on large collections, consider whether users actually need the exact number. "More than 1,000 results" or a simple "Load more" button is often good enough, and it avoids a second expensive query on every page view. If you do need the count, you can cap it:

db.posts.countDocuments({ status: "published" }, { limit: 1001 });

If the result is 1,001, display "1,000+." You can also cache counts for a short time, since they rarely need to be exact to the second.

Pagination Inside Aggregation Pipelines

When a list comes from an aggregation, pagination stages belong at the right place in the pipeline. Put $match and $sort early so they can use indexes, then $skip and $limit, and only then expensive stages like $lookup:

db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $sort: { shippedAt: -1, _id: -1 } },
  { $skip: 40 },
  { $limit: 20 },
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer",
    },
  },
  { $unwind: "$customer" },
]);

Running the $lookup before $limit would join every shipped order, only to throw most of them away. Range-based pagination works in pipelines too: just put the boundary conditions in the first $match.

If you need the page and the total count in one round trip, $facet can run both branches over the same input. It's convenient, but it can't use indexes inside its sub-pipelines and all matched documents flow into it, so it doesn't make counting cheaper. The post on using $facet for multi-faceted search results covers where it shines.

Choosing the Right Approach

Use skip/limit when:

  • The UI genuinely needs "jump to page 37" (admin tables, search results where users skim numbered pages).
  • The filtered result set is modest, or users realistically only look at the first few pages.
  • Occasional duplicates or skipped rows during concurrent writes are acceptable.

Use range-based pagination when:

  • You're building infinite scroll, a "Load more" button, or a public API.
  • A background job needs to walk an entire collection reliably.
  • The collection is large and deep pages must stay fast.
  • Items are inserted frequently at the top of the list.

A hybrid is also common: range-based pagination for the main experience, with a capped skip() for the first handful of numbered pages. Many sites simply refuse to serve offset pages beyond a limit (for example, page 100), which protects the database from crawlers requesting page 90,000.

Common Pitfalls

Sorting on a non-unique field without a tie-breaker. This produces duplicates and gaps with both approaches. Always end the sort with _id (or another unique field) and include it in the range condition.

Mismatched sort and index. An index on { createdAt: -1 } doesn't fully support a sort on { createdAt: -1, _id: -1 } combined with a status filter. Build the compound index that matches your filter and sort exactly, then confirm with explain() that there's no in-memory SORT stage.

Storing dates as strings. Range conditions on ISO strings mostly work until you meet inconsistent formats or time zone suffixes. Store real Date values so comparisons are correct and compact.

Letting clients control page size and sort freely. Unbounded page sizes and arbitrary sort fields turn your list endpoint into a way to run unindexed full scans. Clamp the size and whitelist the sort options.

Counting on every request. A countDocuments() call on every page load can cost more than the page query itself. Cap it, cache it, or drop it.

Paginating over a field that changes. If you sort by updatedAt or a score that changes while users scroll, documents can move across the cursor boundary and be seen twice or never. Paginate on a stable field, or accept that a live-ranked list is a snapshot problem, not a pagination problem.

Conclusion

skip() and limit() are the right tool when you need random access to numbered pages over a reasonably sized result set. They're simple, and with a supporting index and clamped inputs they hold up well for shallow pages. Range-based pagination takes a little more code, a tie-breaker, and a matching compound index, but in return every page costs the same, and results stay stable while data changes underneath.

Pick your busiest list endpoint, run explain("executionStats") on a deep page, and compare totalKeysExamined with the page size. If the gap is large, rewrite that one endpoint with an opaque cursor and the limit + 1 trick, and you'll have a pattern you can reuse everywhere else.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading