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);
}
package com.example.feed;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
public final class Cursor {
private final Instant createdAt;
private final Long id;
private Cursor(Instant createdAt, Long id) {
this.createdAt = createdAt;
this.id = id;
}
public static Cursor firstPage() {
return new Cursor(null, null);
}
public Instant createdAt() {
return createdAt;
}
public Long id() {
return id;
}
public boolean isFirstPage() {
return createdAt == null;
}
public String encode() {
String raw = createdAt.toEpochMilli() + ":" + id;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
public static Cursor of(Post post) {
return new Cursor(post.getCreatedAt(), post.getId());
}
public static Optional<Cursor> decode(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
try {
String raw = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
Instant createdAt = Instant.ofEpochMilli(Long.parseLong(raw.substring(0, sep)));
Long id = Long.parseLong(raw.substring(sep + 1));
return Optional.of(new Cursor(createdAt, id));
} catch (RuntimeException ex) {
throw new IllegalArgumentException("Invalid cursor token", ex);
}
}
}
package com.example.feed;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class FeedService {
private final FeedRepository repository;
public FeedService(FeedRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public FeedResponse loadFeed(Cursor cursor, int limit) {
PageRequest pageRequest = PageRequest.of(0, limit);
Slice<Post> slice = cursor.isFirstPage()
? repository.findFirstPage(pageRequest)
: repository.findAfterCursor(cursor.createdAt(), cursor.id(), pageRequest);
List<PostView> items = slice.getContent().stream()
.map(PostView::from)
.toList();
String nextCursor = null;
if (slice.hasNext() && !slice.getContent().isEmpty()) {
Post last = slice.getContent().get(slice.getContent().size() - 1);
nextCursor = Cursor.of(last).encode();
}
return new FeedResponse(items, nextCursor);
}
public record FeedResponse(List<PostView> items, String nextCursor) {}
public record PostView(Long id, String body, String createdAt) {
static PostView from(Post p) {
return new PostView(p.getId(), p.getBody(), p.getCreatedAt().toString());
}
}
}
package com.example.feed;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/feed")
public class FeedController {
private static final int MAX_LIMIT = 100;
private static final int DEFAULT_LIMIT = 20;
private final FeedService feedService;
public FeedController(FeedService feedService) {
this.feedService = feedService;
}
@GetMapping
public FeedService.FeedResponse feed(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") int limit) {
int safeLimit = Math.max(1, Math.min(limit == 0 ? DEFAULT_LIMIT : limit, MAX_LIMIT));
Cursor parsed = Cursor.decode(cursor)
.orElseGet(Cursor::firstPage);
return feedService.loadFeed(parsed, safeLimit);
}
}
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
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
Share this code
Here's the card — post it anywhere.