java 166 lines · 4 tabs

Cursor-Paginated Feed With Keyset Query and Spring Data Slice

Shared by codesnips Aug 2026
4 tabs
package com.example.feed;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.Instant;

public interface FeedRepository extends JpaRepository<Post, Long> {

    @Query("""
        select p from Post p
        order by p.createdAt desc, p.id desc
        """)
    Slice<Post> findFirstPage(Pageable pageable);

    @Query("""
        select p from Post p
        where p.createdAt < :createdAt
           or (p.createdAt = :createdAt and p.id < :id)
        order by p.createdAt desc, p.id desc
        """)
    Slice<Post> findAfterCursor(@Param("createdAt") Instant createdAt,
                                @Param("id") Long id,
                                Pageable pageable);
}
4 files · java Explain with highlit

Cursor pagination is the practical answer to the performance cliff that OFFSET-based paging hits on large tables: as the offset grows, the database still has to scan and discard all the skipped rows, so page 10,000 becomes catastrophically slow. Keyset pagination instead remembers the last row seen and asks for rows after it, using an indexed comparison. This snippet shows a feed endpoint built entirely around that idea in Spring Boot.

The FeedRepository tab declares a keyset query with @Query. The ordering is (created_at DESC, id DESC) and the WHERE clause uses a lexicographic tuple comparison — a post is included when its createdAt is strictly older than the cursor, or equal but with a smaller id. Including id as a tiebreaker guarantees a total, stable ordering even when many posts share the same timestamp, which is what makes the cursor deterministic. The method returns a Slice rather than a Page, because a Slice only needs LIMIT n+1 to know whether a next page exists and deliberately skips the expensive COUNT(*) that Page requires.

The Cursor value object encodes the (createdAt, id) pair into a single opaque, URL-safe Base64 token via encode, and decode parses it back. Treating the cursor as opaque means clients never construct it themselves and the server is free to change its internal shape later. firstPage handles the initial request where no cursor exists.

The FeedService tab ties it together: it decodes an optional cursor, requests PageRequest.of(0, limit) (offset is always zero in keyset paging — the cursor does the seeking), and inspects Slice.hasNext. When there is a next page it derives the nextCursor from the last element in the returned content. The response DTO carries the items plus that token.

Finally FeedController exposes GET /api/feed, accepting an optional cursor and a bounded limit. Clamping limit prevents a client from requesting an unbounded page. The trade-off of keyset paging is that it only supports next/previous traversal, not random jumps to arbitrary page numbers, and the sort columns must be covered by an index — here a composite index on (created_at, id). For infinite-scroll feeds, that constraint is a perfect fit.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Paginated Feed With Keyset Query and Spring Data Slice — share card
Link copied