MongoDBLimit & Skip Deep Dive

Limit & Skip (Pagination)

limit() and skip() are the obvious tools for pagination, and they work fine at small scale. The catch: skip() gets progressively slower the further into a result set you page, because MongoDB still has to walk past every skipped document. For deep pagination, range-based (cursor) pagination is the pattern production systems actually use.

Basic limit/skip Pagination

Page-number style pagination

JS
const PAGE_SIZE = 20

function getPage(pageNumber) {
  return db.products
    .find()
    .sort({ _id: 1 })
    .skip((pageNumber - 1) * PAGE_SIZE)
    .limit(PAGE_SIZE)
    .toArray()
}

getPage(1)    // skip 0, limit 20
getPage(50)   // skip 980, limit 20
Why skip() Gets Slow

skip(n) does not jump directly to the nth document. MongoDB still has to scan and discard every one of the n skipped documents (or index entries) before it can start returning results. As n grows, so does the wasted work — page 5,000 does roughly 5,000× the work of page 1 for the same page size.

Page

skip() Value

Documents Scanned Before Returning Results

1

0

0

100

1,980

1,980

10,000

199,980

199,980

Warning
Deep skip() pagination (page 1,000+) is one of the most common causes of slow, unpredictable query latency in production MongoDB apps. It gets worse — not better — as the collection grows.
Range-Based (Cursor) Pagination

Instead of asking "skip N, give me the next 20," remember the last value you saw and ask "give me the next 20 documents after that value." This turns pagination into an indexed range query — constant-time regardless of how deep you page.

Cursor pagination using _id

JS
// First page — no cursor yet
let page = db.products.find().sort({ _id: 1 }).limit(20).toArray()

// Remember the last document's _id
let lastId = page[page.length - 1]._id

// Next page — resume strictly after lastId, using the SAME index
page = db.products
  .find({ _id: { $gt: lastId } })
  .sort({ _id: 1 })
  .limit(20)
  .toArray()

Cursor pagination using a timestamp (with a tiebreaker)

JS
// A timestamp alone can collide across documents, so pair it with _id as a tiebreaker
db.events.find({
  $or: [
    { createdAt: { $gt: lastCreatedAt } },
    { createdAt: lastCreatedAt, _id: { $gt: lastId } }
  ]
})
.sort({ createdAt: 1, _id: 1 })
.limit(20)
Tip
Range-based pagination requires an index on the field(s) you're paging by — typically { _id: 1 } (already free) or a compound index like { createdAt: 1, _id: 1 }. As long as that index exists, every page costs the same, regardless of how deep you go.
Bucket Pattern — an Alternative for Fixed Batches

Instead of paginating a huge flat collection, the bucket pattern groups related documents (e.g. sensor readings, log lines) into pre-sized "bucket" documents that each hold a fixed batch (say, 100 readings). Reading a bucket returns 100 records in one document fetch instead of 100 separate reads or one giant skip/limit scan.

A bucket document

JS
{
  _id: ObjectId("..."),
  sensorId: "sensor-42",
  bucketStart: ISODate("2024-01-01T00:00:00Z"),
  count: 100,
  readings: [
    { ts: ISODate("2024-01-01T00:00:01Z"), value: 21.4 },
    { ts: ISODate("2024-01-01T00:00:02Z"), value: 21.5 }
    // ... up to 100 readings per bucket
  ]
}
Note
The bucket pattern trades query flexibility (you can't easily filter a single reading with an index) for dramatically fewer documents to scan and less per-document overhead — a good fit for time-series-like, append-mostly data. MongoDB's native time series collections automate a version of this pattern for you.
Putting It Together — API Pagination Example

A cursor-paginated API endpoint

JS
// GET /products?after=<lastId>&limit=20
async function listProducts(afterId, limit = 20) {
  const filter = afterId ? { _id: { $gt: new ObjectId(afterId) } } : {}

  const docs = await db.collection("products")
    .find(filter)
    .sort({ _id: 1 })
    .limit(limit + 1)   // fetch one extra to know if there's a next page
    .toArray()

  const hasMore = docs.length > limit
  const page = hasMore ? docs.slice(0, limit) : docs

  return {
    items: page,
    nextCursor: hasMore ? page[page.length - 1]._id : null
  }
}
> listProducts(null, 2)
{
  items: [
    { _id: ObjectId('66a1...'), name: 'Widget' },
    { _id: ObjectId('66a2...'), name: 'Gadget' }
  ],
  nextCursor: ObjectId('66a2...')
}

> listProducts('66a2c2d3e4f5a6b7c8d9e0f2', 2)
{
  items: [
    { _id: ObjectId('66a3...'), name: 'Gizmo' }
  ],
  nextCursor: null
}
  • skip() + limit() is fine for shallow pagination (first few pages, admin UIs) but gets linearly slower with page depth.

  • Range-based (cursor) pagination — filter on _id $gt lastId — keeps every page O(limit), regardless of depth.

  • The bucket pattern batches many small records into fewer, larger documents for append-heavy, time-ordered data.

  • Always pair cursor pagination with a supporting index on the sort/filter field.