Cursor Pagination Is an Interview Contract, Not a Database Trick
Cursor pagination is the answer when a feed must remain navigable while rows are arriving. The important part is not the encoded string. It is the contract: define one stable order, place the next query strictly after one visible row, and prove that adjacent pages neither overlap nor skip a row.
That is a much stronger interview answer than “offset gets slow, so I would use a cursor.”
What does a cursor have to mean?
A cursor is a boundary in an ordered set. It is not an arbitrary database primary key and it is not a page number in disguise.
Imagine an activity feed sorted newest first. Sorting only by createdAt DESC looks reasonable until two events share the same timestamp. If page one ends at 10:00:01, a next-page predicate such as createdAt < 10:00:01 drops every sibling created during that second. Using <= fixes the gap by creating a duplicate.
The practical fix is a unique, deterministic compound order:
ORDER BY created_at DESC, id DESC
The cursor carries both fields from the final visible row. The next page asks for rows strictly after that boundary in the same order:
WHERE created_at < :cursorCreatedAt
OR (created_at = :cursorCreatedAt AND id < :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT :pageSize + 1
The extra row answers a separate question: is there another page? Return the first pageSize rows, then derive the next cursor from the final returned row only when the extra row exists.
Can you prove the boundary instead of describing it?
Here is a dependency-free Node.js drill. It deliberately gives two rows the same timestamp, which is where a timestamp-only cursor usually fails.
const assert = require("node:assert/strict");
const compare = (a, b) =>
b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id);
const encode = ({ createdAt, id }) =>
Buffer.from(JSON.stringify({ createdAt, id })).toString("base64url");
const decode = (token) =>
JSON.parse(Buffer.from(token, "base64url").toString());
function page(rows, after, limit) {
const boundary = after && decode(after);
return rows
.slice()
.sort(compare)
.filter((row) => !boundary || compare(row, boundary) > 0)
.slice(0, limit);
}
const rows = [
{ id: "d", createdAt: "2026-08-06T10:00:02.000Z" },
{ id: "c", createdAt: "2026-08-06T10:00:01.000Z" },
{ id: "b", createdAt: "2026-08-06T10:00:01.000Z" },
{ id: "a", createdAt: "2026-08-06T10:00:00.000Z" },
];
const first = page(rows, null, 2);
const second = page(rows, encode(first.at(-1)), 2);
assert.deepEqual(first.map((row) => row.id), ["d", "c"]);
assert.deepEqual(second.map((row) => row.id), ["b", "a"]);
const delivered = [...first, ...second].map((row) => row.id);
assert.deepEqual(delivered, ["d", "c", "b", "a"]);
assert.equal(new Set(delivered).size, delivered.length);
console.log("cursor pagination assertions passed");
The last two assertions are the point of the exercise. Together, they assert the two properties a caller cares about: every expected row appears, and no row appears twice.
Why is offset not the same contract?
Offset pagination is fine for a static admin table, exports, or a result set with a fixed snapshot. It becomes misleading when new records can land before the next offset.
Suppose a client receives rows 1 through 20, then three newer rows arrive. OFFSET 20 now begins three rows later than the caller expects. Depending on the direction of writes, the client sees duplicates, gaps, or both. A cursor anchored to the last seen row does not promise a frozen universe, but it does keep moving forward from a known boundary.
That distinction matters in an interview. Say what your product needs:
| Requirement | Design consequence |
|---|---|
| Infinite feed | Compound cursor and stable ordering |
| Jump to page 47 | Offset or a separate indexed navigation model |
| Repeatable export | Snapshot timestamp or transaction |
| Newly inserted rows | Decide whether they appear only after refresh |
A good answer also names the limitation. Cursor pagination cannot provide cheap random page access, and a mutable sort key can move a row across a boundary. For a timeline, prefer an immutable creation timestamp. For a ranking feed, use an explicit snapshot or accept that refreshed results can reorder.
What should the API expose?
Keep the token opaque. A base64 JSON token is convenient in a demo, but clients should not build behavior around its fields. The response shape can remain small:
{
"items": [{ "id": "d" }, { "id": "c" }],
"nextCursor": "eyJjcmVhdGVkQXQiOiIuLi4iLCJpZCI6ImMifQ"
}
On the server, validate decoding, field types, and expected sort version. If the feed's ordering ever changes, a versioned cursor lets the API reject old tokens clearly instead of silently returning a wrong continuation.
In a real database, index the ordering columns in the same direction as the query where the engine supports it. Then inspect the query plan with a realistic cursor, not just the first page. The first page is often cheap; the continuation query is the one this design is meant to protect.
How would I say this out loud?
My compact answer would be:
“I would define a unique order first, usually
created_at DESC, id DESC. The cursor is the last visible tuple, and the next query uses a strict lexicographic predicate against that tuple. I fetch one extra row to computehasNextPage, and I test equal timestamps because that is where gaps and duplicates appear. If the product needs page 47 or a repeatable export, I would choose a different contract.”
That is concrete enough to invite follow-up questions about indexes, snapshots, and changing rank scores.
For practice, I find it useful to explain the predicate before writing it and then let a mock interviewer challenge one assumption at a time. aceround.app, an AI interview assistant, is one option for rehearsing that kind of technical follow-up without turning the session into a memorized script.
FAQ
Why not put only the ID in the cursor?
That works only when the feed is ordered by that same unique ID. If ordering is by timestamp, score, or another field, the cursor must contain enough data to reproduce the boundary.
Should a cursor be encrypted?
Not necessarily. It should be opaque to clients and validated by the server. Sign it when tampering would create a meaningful risk or reveal sensitive data.
Does cursor pagination eliminate all consistency issues?
No. It gives a clear continuation boundary. Snapshot requirements, mutable rank fields, and deletions still need an explicit product decision.
AI assistance was used for drafting. The code and pagination invariants were reviewed and executed before publication.
Top comments (0)