java 104 lines · 3 tabs

Keyset (Cursor) Pagination with Spring Data JPA and Base64 Cursors

Shared by codesnips Jul 2026
3 tabs
package com.example.feed;

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;

public record Cursor(Instant createdAt, long id) {

    public String encode() {
        String raw = createdAt.toEpochMilli() + ":" + id;
        return Base64.getUrlEncoder().withoutPadding()
                .encodeToString(raw.getBytes(StandardCharsets.UTF_8));
    }

    public static Cursor decode(String token) {
        try {
            byte[] bytes = Base64.getUrlDecoder().decode(token);
            String[] parts = new String(bytes, StandardCharsets.UTF_8).split(":", 2);
            return new Cursor(Instant.ofEpochMilli(Long.parseLong(parts[0])),
                    Long.parseLong(parts[1]));
        } catch (RuntimeException ex) {
            throw new IllegalArgumentException("Malformed cursor token", ex);
        }
    }

    public static Cursor of(Article article) {
        return new Cursor(article.getCreatedAt(), article.getId());
    }
}
3 files · java Explain with highlit

Offset pagination (LIMIT ? OFFSET ?) degrades badly on large tables because the database still scans and discards every skipped row, and it can silently skip or duplicate rows when data changes between page loads. Keyset pagination avoids both problems by remembering the last row seen and asking for rows after it, using a stable, indexed ordering. This set of files shows how to implement it end to end in a Spring Boot service.

The Cursor record in the first tab encapsulates the position between pages. Because the sort is on createdAt (which is not unique) the cursor also carries the row id as a tiebreaker, so the ordering is total and deterministic. Cursor.encode and Cursor.decode serialize this into an opaque URL-safe Base64 token; clients treat it as a blob and never construct it themselves, which keeps the ordering columns an implementation detail that can change later.

ArticleRepository defines two @Query methods. findFirstPage handles the initial request with no cursor, while findAfter implements the keyset predicate. The comparison (a.createdAt < :createdAt) OR (a.createdAt = :createdAt AND a.id < :id) is the row-value comparison expressed in JPQL — it selects everything strictly after the cursor under the compound (createdAt DESC, id DESC) ordering. A composite index on (created_at, id) lets Postgres jump straight to the start position, so each page costs the same regardless of how deep the client has paged.

Both queries request limit + 1 rows via Pageable. The service reads one extra row to detect whether a further page exists without a separate COUNT query, which is the other expensive part of classic pagination.

ArticleService.getPage in the third tab ties it together: it decodes the incoming cursor, picks the first-page or after-cursor query, trims the sentinel row, and builds the next cursor from the last returned entity. When fewer than limit + 1 rows come back, nextCursor is left null to signal the end. The returned CursorPage gives clients exactly what they need to fetch the following page.

The main trade-off is that keyset pagination only supports next/previous navigation, not jumping to an arbitrary page number, and the sort columns must be immutable and indexed. For infinite-scroll feeds and large datasets that fits perfectly.


Related snips

Share this code

Here's the card — post it anywhere.

Keyset (Cursor) Pagination with Spring Data JPA and Base64 Cursors — share card
Link copied