A next-page button needs a precise definition of after. If several records share the same timestamp, a boundary using only that timestamp can omit some of them. Compound keyset pagination stores the full ordering position, including a unique tie-breaker. This MongoDB 8.0 guide extends simple range pagination with descending timestamps, duplicate dates, tenant filtering, and a lookahead row. All examples use numeric identifiers for transparent output; a production collection using ObjectId must encode and decode that BSON type correctly. The fixtures use disposable named collections and do not require a sharded deployment or external application.
Specify one total ordering
Choose a sort that orders every result unambiguously. The main example sorts createdAt descending and _id descending. Because _id is unique in the collection, two records sharing createdAt still have a deterministic order. Sorting only by a nonunique timestamp does not promise which tied record appears first. A stable tie-breaker matters for both ordinary pagination and reproducible tests. Keep sort direction consistent with the boundary comparisons.
For descending order, records after the last tuple have an older timestamp, or the same timestamp and a smaller identifier. Those two alternatives form an $or. For ascending order, use greater-than comparisons instead. Do not change only the sort direction while keeping the old comparison operators. A next-page token needs both values, not just the visible date. The tuple describes a position in one ordering contract; reusing it with another filter or sort can produce misleading pages.
Retain the query scope and matching index
The page boundary is only one part of the filter. Keep the tenant, category, status, and authorization scope that defined the original list. In the fixture, every page still requires tenant A. A token supplied by a caller must not choose a different tenant or bypass access checks. Validate and bind the token to the server's current query contract where appropriate. Signing a token can protect its integrity, but it does not authorize the underlying data by itself.
A compound index beginning with the equality scope and followed by the ordering fields can support this query pattern. The fixture creates { tenant: 1, createdAt: -1, _id: -1 }. Verify the actual plan with representative data and explain rather than promising one universal index result. Additional filters and multikey fields can change the plan's behavior. Keyset pagination avoids the growing skipped prefix, but it does not make every query efficient regardless of its predicates, document size, or index design.
Use a lookahead row without advancing too far
Request pageSize plus one documents to determine whether another page exists. Return only the first pageSize and set hasMore from the extra row. Construct the next token from the last returned document, not from the extra lookahead row. Advancing to that unseen row would omit it from the next page. This is a small off-by-one mistake with a large practical effect because the interface appears to work while silently losing records.
Validate page size and cap it. An invalid or enormous value should not become an unrestricted query. Encode dates and identifiers in a format that can be restored to their correct BSON types. Comparing a stored Date against an unconverted string is not the same query. Tokens should contain only the fields needed for the boundary, not an entire document or sensitive payload. A token is an application pagination cursor, distinct from the server retrieval cursor discussed in the cursor-lifecycle reference.
Define behavior when records change
Keyset pagination is not automatically a fixed snapshot. A new record before the boundary may not appear during a forward walk, and changing a record's sort key can move it across pages. That can create omissions or duplicates under concurrent edits. Prefer immutable ordering fields for stable feeds and document the interface's freshness behavior. A createdAt cutoff can bound newer inserts, but it does not freeze updates or deletes. Use a suitable consistent read strategy when the product needs a complete point-in-time report.
Keyset navigation is best suited to next and previous browsing, not direct jumps to an arbitrary page number. A previous-page implementation needs reversed boundaries and careful result-order restoration. Do not disguise that tradeoff by deriving a page number from the token. Offset pagination can still be appropriate for small administrative lists or a product requiring numbered jumps, with known cost and consistency limits. Choose according to user behavior and real data volume. The static fixture tests tuple correctness without pretending to resolve every live-feed consistency requirement.
MongoDB documentation and version notes
MongoDB Compound Keyset Pagination Examples
Each example shows the code, the output it produces and what each part does.
Paging tenant rows with tied timestamps
Run the complete mongosh script on a MongoDB 8.0 fixture. Five tenant-A rows contain two duplicate timestamp groups, and a newer tenant-B row tests that the scope remains intact. A page size of two returns three pages. Each call requests one lookahead row, slices the returned data, and builds its next boundary from the last visible row. The expected identifiers show that neither tied group loses a member. The unrelated tenant never appears. Output is reduced to identifiers and hasMore so the assertion tests pagination semantics without depending on shell formatting of dates or complete documents.
const rows = db.getSiblingDB('codevra_ref_pages').getCollection('feed');
rows.deleteMany({});
rows.insertMany([
{ _id: 1, tenant: 'A', createdAt: ISODate('2026-01-01T00:00:00Z') },
{ _id: 2, tenant: 'A', createdAt: ISODate('2026-01-02T00:00:00Z') },
{ _id: 3, tenant: 'A', createdAt: ISODate('2026-01-02T00:00:00Z') },
{ _id: 4, tenant: 'A', createdAt: ISODate('2026-01-03T00:00:00Z') },
{ _id: 5, tenant: 'A', createdAt: ISODate('2026-01-03T00:00:00Z') },
{ _id: 6, tenant: 'B', createdAt: ISODate('2026-01-04T00:00:00Z') }
]);
rows.createIndex({ tenant: 1, createdAt: -1, _id: -1 });
function page(last) {
const filter = { tenant: 'A' };
if (last) filter.$or = [{ createdAt: { $lt: last.createdAt } },
{ createdAt: last.createdAt, _id: { $lt: last._id } }];
const found = rows.find(filter).sort({ createdAt: -1, _id: -1 }).limit(3).toArray();
const visible = found.slice(0, 2);
const hasMore = found.length > 2;
return { ids: visible.map(x => x._id), hasMore, next: hasMore ? visible.at(-1) : null };
}
let last = null;
do {
const result = page(last);
print(JSON.stringify({ ids: result.ids, hasMore: result.hasMore }));
last = result.next;
} while (last);{"ids":[5,4],"hasMore":true}
{"ids":[3,2],"hasMore":true}
{"ids":[1],"hasMore":false}MongoDB Compound Keyset Pagination in Real Projects
Page by score with an identifier tie-breaker
Implement an ascending score boundary with an identifier tie-breaker. The first two rows share score ten. After returning both, select the next rows with a higher score or the same score and a higher identifier. This independent fixture reverses the direction used in the main guide, forcing the comparisons to follow the declared order. Print the first and second pages as identifier arrays. A boundary that uses score greater than ten happens to produce the same second page here, but add a third tied row as a deliberate follow-up test to reveal why the tie clause is required.
const rows = db.getSiblingDB('codevra_ref_pages').getCollection('scores');
rows.deleteMany({});
rows.insertMany([{ _id: 1, score: 10 }, { _id: 2, score: 10 },
{ _id: 3, score: 15 }, { _id: 4, score: 20 }]);
const first = rows.find().sort({ score: 1, _id: 1 }).limit(2).toArray();
const last = first.at(-1);
const second = rows.find({ $or: [{ score: { $gt: last.score } },
{ score: last.score, _id: { $gt: last._id } }] }).sort({ score: 1, _id: 1 }).limit(2).toArray();
print(JSON.stringify(first.map(x => x._id)));
print(JSON.stringify(second.map(x => x._id)));[1,2]
[3,4]MongoDB Compound Keyset Pagination FAQ
Why include _id when timestamps already sort the feed?
Several rows can share a timestamp. A unique tie-breaker gives each one a distinct ordering position. The token and boundary must include both fields. A timestamp-only strict comparison can skip remaining tied rows. Choose a tie-breaker that is unique in the relevant scope and preserve its actual BSON type when encoding the token. Determinism is a property of the whole sort tuple.
Which row should produce the next token?
Use the last row actually returned to the caller. The extra lookahead row only establishes that another result exists. Building a token from that unseen row advances past it, so it can disappear from the user's walk. Keep the page-size slice and boundary construction together in the implementation and test a result count just above the page size to expose this off-by-one error.
Does keyset pagination freeze changing data?
No. Inserts, deletes, and mutable sort keys can change the list during a walk. Immutable ordering fields and a cutoff can improve a feed's behavior, but a cutoff does not freeze every edit. Use a consistency strategy appropriate to a point-in-time report when that is required. Document whether the interface is a live feed or a snapshot rather than implying the pagination technique supplies both.
Can a token replace tenant authorization?
No. Keep the server-derived tenant and access scope in every query. A signed token can protect its contents from alteration, but it does not grant permission to see another tenant's documents. Validate its types and bind it to the expected query contract. Avoid trusting a caller-provided tenant field simply because it appears beside a valid-looking timestamp and identifier.
MongoDB Compound Keyset Pagination Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Which identifiers appear on the second main page after [5,4]?
MongoDB Compound Keyset Pagination Quiz
2 questions to check what you learned.
Why request pageSize plus one?
- To detect another page while returning only pageSize rows
- To include the extra row in the next token automatically
- To make the query a fixed snapshot
For ascending (score, _id), what comparison identifies later tuples?
- Higher score, or equal score and higher _id
- Lower score, or equal score and lower _id
- Equal score only, ignoring _id