Cursor pagination becomes reliable only when the HTTP contract, cursor token, and database ordering describe the same position.
A cursor is not just an encoded row ID. It must preserve a stable boundary, reject incompatible filters, survive concurrent inserts, and give clients a clear next action.
Quick answer
For a newest-first endpoint, sort by a business field plus a unique tie-breaker:
ORDER BY created_at DESC, id DESC
After the last item on a page, query the next page with a keyset boundary:
WHERE (created_at, id) < (:createdAt, :id)
ORDER BY created_at DESC, id DESC
LIMIT :pageSizePlusOne
Encode the boundary and relevant query context in an opaque, authenticated cursor:
{
"v": 1,
"createdAt": "2026-07-18T14:30:00.000Z",
"id": "evt_01JZ8Y",
"filterHash": "sha256:...",
"exp": 1784471400
}
Return items and page information separately:
{
"items": [],
"pageInfo": {
"limit": 50,
"hasNextPage": false,
"nextCursor": null
}
}
Base64url encoding is not encryption or tamper protection. Sign the cursor when changing its values could cross authorization boundaries, bypass filters, increase cost, or expose unsupported query positions.
Start with the access pattern
Cursor pagination is best for feeds, event lists, audit logs, public APIs, and other changing datasets where clients usually move forward.
It is not automatically best for every UI. Offset pagination remains convenient for small admin tables that need page numbers and arbitrary jumps.
Read Pagination Strategies for REST APIs for the initial choice and API Pagination Metadata Best Practices for response fields.
A concrete endpoint in this guide is:
GET /events?status=published&limit=50&cursor=opaque_token
Its contract is:
- Newest events first.
- Maximum page size of 100.
- Stable order by created_at descending, then id descending.
- Cursor bound to the status filter and sort.
- Cursor expires after a documented period.
- Invalid or expired cursors return a stable 400 problem.
- Items inserted after the first page do not shift the continuation boundary.
Why one sort field is not enough
This order is not deterministic:
ORDER BY created_at DESC
Several rows can share the same timestamp. Their relative order may change between queries, which can produce duplicates or omissions.
Add a unique tie-breaker:
ORDER BY created_at DESC, id DESC
The cursor must contain both values. If the last row is:
created_at = 2026-07-18T14:30:00.000Z
id = evt_01JZ8Y
the next query continues strictly after that row in the chosen order.
For descending order:
WHERE created_at < :createdAt
OR (created_at = :createdAt AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT :limit
PostgreSQL supports equivalent row-value comparison:
WHERE (created_at, id) < (:createdAt, :id)
ORDER BY created_at DESC, id DESC
LIMIT :limit
The matching index is usually:
CREATE INDEX events_created_at_id_desc
ON events (created_at DESC, id DESC);
Check the real execution plan. The correct index also depends on tenant and filter columns.
For a tenant-scoped endpoint:
CREATE INDEX events_tenant_status_created_id
ON events (
tenant_id,
status,
created_at DESC,
id DESC
);
The query must always constrain tenant_id before applying the cursor boundary.
Fetch one extra row
Do not run a separate count just to calculate hasNextPage.
If the requested limit is 50, fetch 51 rows:
SELECT id, type, created_at, payload
FROM events
WHERE tenant_id = :tenantId
AND status = :status
AND (created_at, id) < (:createdAt, :id)
ORDER BY created_at DESC, id DESC
LIMIT 51;
Then:
- If 51 rows arrive, return the first 50 and set hasNextPage to true.
- Build nextCursor from row 50, the last row actually returned.
- If 50 or fewer arrive, return all rows and set hasNextPage to false.
- Return nextCursor as null at the end.
Do not build the cursor from the extra row. That would skip it on the next request.
This technique avoids an exact total count, which may be expensive or misleading while data changes.
Design the cursor payload
A useful internal payload includes only what the server needs:
{
"v": 1,
"createdAt": "2026-07-18T14:30:00.000Z",
"id": "evt_01JZ8Y",
"filterHash": "sha256:7a1c...",
"exp": 1784471400
}
Field purposes:
| Field | Purpose |
|---|---|
| v | Cursor format version |
| createdAt | Primary keyset boundary |
| id | Unique tie-breaker |
| filterHash | Binding to normalized filters and sort |
| exp | Optional expiration time |
Do not include tenantId from the cursor and then trust it for authorization. Resolve tenant identity from the authenticated request. The cursor may prove query context, but it does not grant access.
Normalize filters before hashing. For example, status=published and a default sort should produce the same context whether the client explicitly sent the default or omitted it.
Cursor versioning lets the server reject or migrate old formats deliberately. It does not require keeping every historical cursor forever.
Opaque does not mean secret
Clients should treat the cursor as an opaque string. That means the client cannot construct or depend on its internal shape.
It does not necessarily mean the payload is confidential. A base64url-encoded JSON document can be decoded by anyone.
Choose protection based on risk:
| Protection | Provides | Does not provide |
|---|---|---|
| Base64url | Transport-safe encoding | Integrity or confidentiality |
| HMAC signature | Integrity and authenticity | Confidentiality |
| Authenticated encryption | Integrity and confidentiality | Authorization by itself |
| Server-side cursor ID | Opaque lookup handle | Stateless operation |
HMAC is a strong default for stateless cursors. Authenticated encryption is useful if the boundary contains information that should not be visible. A random server-side handle is useful when cursors need revocation or large state, but it adds storage and cleanup.
Never use a plain hash as a signature. A hash without a secret does not prevent a client from recomputing it after changing the payload.
Java cursor codec
A focused Java codec can serialize a known payload, encode it with base64url, and authenticate it with HMAC-SHA-256.
public record EventCursor(
int v,
Instant createdAt,
String id,
String filterHash,
long exp
) {}
public final class CursorCodec {
private final ObjectMapper mapper;
private final SecretKey key;
public CursorCodec(ObjectMapper mapper, byte[] secret) {
this.mapper = mapper;
this.key = new SecretKeySpec(secret, "HmacSHA256");
}
public String encode(EventCursor cursor) {
try {
byte[] payload = mapper.writeValueAsBytes(cursor);
byte[] signature = sign(payload);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(payload)
+ "."
+ Base64.getUrlEncoder().withoutPadding()
.encodeToString(signature);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cursor serialization failed", exception);
}
}
public EventCursor decode(String value, Instant now) {
try {
String[] parts = value.split("\\.", -1);
if (parts.length != 2) {
throw new InvalidCursorException("invalid_cursor");
}
byte[] payload = Base64.getUrlDecoder().decode(parts[0]);
byte[] suppliedSignature =
Base64.getUrlDecoder().decode(parts[1]);
if (!MessageDigest.isEqual(sign(payload), suppliedSignature)) {
throw new InvalidCursorException("invalid_cursor");
}
EventCursor cursor =
mapper.readValue(payload, EventCursor.class);
if (cursor.v() != 1 || cursor.exp() < now.getEpochSecond()) {
throw new InvalidCursorException("expired_or_unsupported_cursor");
}
return cursor;
} catch (IllegalArgumentException | IOException exception) {
throw new InvalidCursorException("invalid_cursor", exception);
}
}
private byte[] sign(byte[] payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
return mac.doFinal(payload);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Cursor signing failed", exception);
}
}
}
Use a secret from a managed secret store, not source control. Support rotation by attaching a key identifier or trying a small set of active verification keys.
Set a decoded payload size limit before JSON parsing. A client can send an arbitrarily large string unless the HTTP layer and codec reject it.
Spring Boot service flow
Keep pagination orchestration separate from cursor encoding:
public EventPage listEvents(
AuthenticatedTenant tenant,
EventQuery query
) {
int limit = Math.min(Math.max(query.limit(), 1), 100);
String filterHash = query.normalizedFilterHash();
EventCursor cursor = query.cursor() == null
? null
: cursorCodec.decode(query.cursor(), clock.instant());
if (cursor != null && !cursor.filterHash().equals(filterHash)) {
throw new InvalidCursorException("cursor_filter_mismatch");
}
List<Event> rows = repository.findPage(
tenant.id(),
query.status(),
cursor == null ? null : cursor.createdAt(),
cursor == null ? null : cursor.id(),
limit + 1
);
boolean hasNextPage = rows.size() > limit;
List<Event> items = hasNextPage
? rows.subList(0, limit)
: rows;
String nextCursor = null;
if (hasNextPage) {
Event last = items.get(items.size() - 1);
nextCursor = cursorCodec.encode(new EventCursor(
1,
last.createdAt(),
last.id(),
filterHash,
clock.instant().plus(Duration.ofHours(24)).getEpochSecond()
));
}
return new EventPage(
items,
new PageInfo(limit, hasNextPage, nextCursor)
);
}
Inject a Clock so expiration tests are deterministic. Avoid calling Instant.now throughout the method.
The repository must include tenant and filters in the same query that applies the keyset boundary. Filtering in Java after querying can leak data and break page sizing.
Node.js cursor codec
Node.js provides base64url encoding and cryptographic HMAC support:
import {
createHmac,
timingSafeEqual
} from "node:crypto";
export function createCursorCodec(secret) {
function signature(payload) {
return createHmac("sha256", secret)
.update(payload)
.digest();
}
return {
encode(cursor) {
const payload = Buffer.from(
JSON.stringify(cursor),
"utf8"
);
const encodedPayload = payload.toString("base64url");
const encodedSignature = signature(payload)
.toString("base64url");
return encodedPayload + "." + encodedSignature;
},
decode(value, nowSeconds) {
if (typeof value !== "string" || value.length > 2048) {
throw new InvalidCursorError("invalid_cursor");
}
const parts = value.split(".");
if (parts.length !== 2) {
throw new InvalidCursorError("invalid_cursor");
}
let payload;
let suppliedSignature;
try {
payload = Buffer.from(parts[0], "base64url");
suppliedSignature = Buffer.from(parts[1], "base64url");
} catch {
throw new InvalidCursorError("invalid_cursor");
}
const expectedSignature = signature(payload);
if (
expectedSignature.length !== suppliedSignature.length ||
!timingSafeEqual(expectedSignature, suppliedSignature)
) {
throw new InvalidCursorError("invalid_cursor");
}
const cursor = JSON.parse(payload.toString("utf8"));
if (cursor.v !== 1 || cursor.exp < nowSeconds) {
throw new InvalidCursorError("expired_or_unsupported_cursor");
}
return cursor;
}
};
}
The Node.js Buffer documentation documents base64url support. The signature check first compares lengths because timingSafeEqual requires equal-size buffers.
Validate the parsed object with a schema. JSON.parse succeeding does not prove field types, timestamp ranges, ID format, or allowed keys.
Node.js request flow
A route handler can fetch one extra row and return the page:
app.get("/events", async (req, res, next) => {
try {
const limit = Math.min(
Math.max(Number(req.query.limit) || 50, 1),
100
);
const filter = normalizeEventFilter(req.query);
const filterHash = hashFilter(filter);
const cursor = req.query.cursor
? cursorCodec.decode(
req.query.cursor,
Math.floor(Date.now() / 1000)
)
: null;
if (cursor && cursor.filterHash !== filterHash) {
throw new InvalidCursorError("cursor_filter_mismatch");
}
const rows = await eventRepository.findPage({
tenantId: req.user.tenantId,
status: filter.status,
createdAt: cursor?.createdAt ?? null,
id: cursor?.id ?? null,
limit: limit + 1
});
const hasNextPage = rows.length > limit;
const items = hasNextPage ? rows.slice(0, limit) : rows;
const last = items.at(-1);
const nextCursor =
hasNextPage && last
? cursorCodec.encode({
v: 1,
createdAt: last.createdAt.toISOString(),
id: last.id,
filterHash,
exp: Math.floor(Date.now() / 1000) + 86400
})
: null;
res.json({
items,
pageInfo: { limit, hasNextPage, nextCursor }
});
} catch (error) {
next(error);
}
});
Return the applied limit, not only the requested value. Clients need to know when the server capped the page size.
Invalid and expired cursors
A malformed cursor is a client error, not an internal server error.
Use one stable problem type:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/invalid-cursor",
"title": "Invalid pagination cursor",
"status": 400,
"detail": "Restart pagination without the cursor."
}
Do not reveal whether the signature, payload, filter hash, key ID, or expiration check failed. That diagnostic detail belongs in safe server logs.
The client recovery action should be explicit. For most list views, restart from the first page. For long-running exports, a cursor expiration may require a different snapshot or asynchronous export design.
Read Problem Details for HTTP APIs for the response format.
Concurrent inserts, updates, and deletes
Cursor pagination provides a stable continuation boundary, not a full database snapshot.
With newest-first ordering:
- New rows inserted after page one usually appear before the cursor and will not shift later pages.
- Rows deleted before the next request simply disappear.
- Rows whose sort key changes can move across the boundary and appear twice or be missed.
- Authorization or filter changes can change visibility between pages.
If strict snapshot consistency is required, options include:
- Include an upper-bound snapshot timestamp in the cursor.
- Read from a database snapshot with an appropriate lifetime.
- Materialize export results.
- Use an immutable sequence or event offset.
- Run a background export and return a downloadable artifact.
Do not promise snapshot semantics unless the storage and cursor actually provide them.
Backward pagination
Previous-page support is not free.
One approach is to reverse the comparison and sort:
WHERE (created_at, id) > (:createdAt, :id)
ORDER BY created_at ASC, id ASC
LIMIT :limitPlusOne
Then reverse the returned rows before sending them so the public order remains newest-first.
The cursor must record direction, and tests must cover page boundaries carefully. If the product only needs infinite scroll or export continuation, omit previous cursors. YAGNI is valuable here.
Index and performance verification
A keyset query is fast only when the database can use an appropriate index.
Use EXPLAIN with production-like data and filters. Verify:
- The index order matches the public sort.
- Tenant and selective filter columns are positioned appropriately.
- The query does not scan a large discarded range.
- Page latency stays stable far into the dataset.
- Cursor decoding and signing are negligible compared with the query.
- Limits cannot be raised beyond the documented maximum.
Do not claim keyset pagination is faster based only on a small local table. Measure offset page 1, a deep offset page, and keyset continuation under realistic cardinality.
Contract tests
Test the complete behavior:
- First page has deterministic order.
- Rows sharing createdAt are ordered by ID.
- Next page has no duplicates or omissions.
- The cursor is built from the last returned row, not the extra row.
- Tampering with payload or signature returns invalid_cursor.
- Expired and unsupported versions fail safely.
- A cursor cannot be reused with different filters or sort.
- A cursor from one tenant cannot access another tenant.
- Limit is capped and the applied value is returned.
- Empty and final pages return hasNextPage false and nextCursor null.
- Inserts before the boundary do not shift continuation.
- Secrets can rotate without breaking the documented grace period.
- Error responses contain no codec details.
Property-based tests are useful for duplicate timestamps and random page sizes. Integration tests against the real database are necessary for SQL ordering and indexes.
Common mistakes
The first mistake is sorting by a non-unique field.
The second mistake is encoding only an ID when the query sorts by timestamp and ID.
The third mistake is treating base64url as tamper protection.
The fourth mistake is trusting tenant or authorization data from the cursor.
The fifth mistake is reusing a cursor after filters or sort change.
The sixth mistake is building nextCursor from the extra fetched row.
The seventh mistake is returning an exact total count on every page without measuring its cost.
The eighth mistake is promising snapshot consistency while mutable sort fields can move.
The ninth mistake is logging the full cursor, which may contain sensitive query context.
Practical recommendation
For a public REST list:
- Use a stable composite order with a unique tie-breaker.
- Back it with an index verified on realistic data.
- Fetch limit plus one to derive hasNextPage.
- Encode version, boundary, filter context, and optional expiry.
- Authenticate the cursor with HMAC or authenticated encryption.
- Resolve authorization from the request, never from the cursor.
- Cap token length, page size, and retry behavior.
- Return a stable invalid-cursor problem.
- Document consistency guarantees honestly.
- Add previous-page support only when the product requires it.
Related reading
Read Pagination Strategies for REST APIs for choosing offset, cursor, or keyset pagination and API Pagination Metadata Best Practices for the public response envelope.
Use Database Indexes Explained for index tradeoffs, API Versioning Explained for cursor format changes, and Problem Details for HTTP APIs for invalid cursor responses. See the GitHub REST pagination documentation for a production API’s link-based pagination contract and the Node.js Buffer documentation for base64url encoding behavior.