Photo by Yosuke Ota on Unsplash

On this page

This chapter follows MongoDB Aggregation Framework. Atlas Search pagination uses searchAfter tokens; the patterns here apply to standard find queries.

When to use which approach

Use offset pagination for low-traffic admin screens where users rarely jump deep into results. Use keyset cursors for infinite scroll, mobile feeds, and any list where deep skip would scan millions of documents. Expose opaque cursor tokens to clients instead of raw ObjectId values when the cursor crosses trust boundaries.

Offset pagination with skip and limit

const page = 3;
const limit = 20;
const skip = (page - 1) * limit;
const data = await col.find({ status: 'open' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.toArray();

Problems:

  • The server must scan and discard skip documents—cost grows with page number.
  • If rows are inserted or deleted between requests, items can shift between pages (duplicates or gaps).

Offset pagination is acceptable for small admin tables with low page numbers.

Keyset pagination

Use an immutable, indexed, unique sort key—often (createdAt, _id):

interface CursorPayload {
createdAt: string;
id: string;
}
function encodeCursor(c: CursorPayload): string {
return Buffer.from(JSON.stringify(c)).toString('base64url');
}
function decodeCursor(token: string): CursorPayload {
return JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
}

Query the next page after a cursor:

const limit = 20;
const after = decodeCursor(token);
const filter = {
status: 'open',
$or: [
{ createdAt: { $lt: new Date(after.createdAt) } },
{ createdAt: new Date(after.createdAt), _id: { $lt: new ObjectId(after.id) } },
],
};
const rows = await col.find(filter)
.sort({ createdAt: -1, _id: -1 })
.limit(limit + 1)
.toArray();
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore
? encodeCursor({
createdAt: data[data.length - 1].createdAt.toISOString(),
id: data[data.length - 1]._id.toHexString(),
})
: undefined;

Create a supporting index:

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

Total counts

Method Cost Use when
countDocuments(filter) Accurate, scans index or collection Exact totals for small filters
estimatedDocumentCount() Fast metadata Rough dashboard totals
$facet with data + count One round trip Admin UI needs both

Keyset APIs often omit total page count because cursors do not map to page numbers.

A typed paginate helper

src/lib/paginate.ts
import { Collection, Document, Filter, Sort } from 'mongodb';
export interface Page<T> {
data: T[];
nextCursor?: string;
}
export async function paginate<T extends Document>(
col: Collection<T>,
filter: Filter<T>,
sort: Sort,
limit: number,
cursor?: string,
decode: (s: string) => Filter<T> = () => ({}),
encode: (doc: T) => string = () => '',
): Promise<Page<T>> {
const query = cursor ? { $and: [filter, decode(cursor)] } : filter;
const rows = await col.find(query).sort(sort).limit(limit + 1).toArray();
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
return {
data,
nextCursor: hasMore && data.length ? encode(data[data.length - 1]) : undefined,
};
}

Wire encode/decode to your cursor format from the keyset section.

Next: Geospatial queries in MongoDB.

Check your understanding

Question 1 of 5

0 of 5 checked

  1. 1. When does the article recommend keyset cursors over offset pagination?

Search articles

Type a word to search published articles.

Open the search page