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
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 20Why 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 |
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
// 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)
// 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){ _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
{
_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
]
}Putting It Together — API Pagination Example
A cursor-paginated API endpoint
// 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.