Spring Boot
Pagination
Spring Data provides first-class pagination support through the Pageable abstraction and Page<T> return type. Controllers accept page, size, and sort parameters automatically; repositories execute COUNT + SELECT queries and return a Page containing the data slice plus total element and page counts. This entry covers setup, Pageable binding, Page responses, cursor-based pagination, and performance considerations.
Pageable Binding in Controllers
Spring MVC resolves a Pageable method parameter from the incoming request's query parameters automatically via PageableHandlerMethodArgumentResolver, which is registered by Spring Boot's auto-configuration. Use @PageableDefault to set sensible defaults when the client omits the parameters.
Java
// ── Request parameters resolved automatically ─────────────────────────
// GET /users?page=0&size=20&sort=createdAt,desc
// page → zero-based page index (default 0)
// size → page size (default 20)
// sort → field,direction — repeatable for multi-field sort
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
@PageableDefault(
page = 0,
size = 20,
sort = "createdAt",
direction = Sort.Direction.DESC
) Pageable pageable) {
return ResponseEntity.ok(userService.findAll(pageable));
}
}
// ── Global defaults via configuration ─────────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(
List<HandlerMethodArgumentResolver> resolvers) {
PageableHandlerMethodArgumentResolver resolver =
new PageableHandlerMethodArgumentResolver();
resolver.setDefaultPageSize(20);
resolver.setMaxPageSize(100); // cap: clients cannot request >100
resolver.setOneIndexedParameters(false); // keep zero-based (default)
resolvers.add(resolver);
}
}Repository and Service Layer
JpaRepository extends PagingAndSortingRepository, so every Spring Data repository already supports Pageable. Pass it straight through from the controller to the repository. The resulting Page<T> contains both the data slice and the total count needed to calculate page navigation.
Java
// ── Repository ────────────────────────────────────────────────────────
public interface UserRepository extends JpaRepository<User, Long> {
// Derived query — pagination applied automatically
Page<User> findByStatus(UserStatus status, Pageable pageable);
// JPQL with pagination
@Query("SELECT u FROM User u WHERE u.department.id = :deptId")
Page<User> findByDepartment(@Param("deptId") Long deptId, Pageable pageable);
// Native query with pagination — countQuery is required
@Query(value = "SELECT * FROM users WHERE role = :role",
countQuery = "SELECT COUNT(*) FROM users WHERE role = :role",
nativeQuery = true)
Page<User> findByRoleNative(@Param("role") String role, Pageable pageable);
}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo;
public Page<UserResponse> findAll(Pageable pageable) {
return userRepo.findAll(pageable)
.map(UserResponse::from); // Page.map preserves metadata
}
public Page<UserResponse> findByStatus(UserStatus status, Pageable pageable) {
return userRepo.findByStatus(status, pageable)
.map(UserResponse::from);
}
}Shaping the Page Response
Spring Data's Page<T> serializes to a verbose JSON envelope containing nested pageable, sort, and other internal objects. Flatten it into a clean, stable API contract using a custom wrapper record. This decouples your API from Spring Data's internal structure and makes the response easier to consume.
Java
// ── Custom page envelope ──────────────────────────────────────────────
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}
// ── Controller using the wrapper ──────────────────────────────────────
@GetMapping
public ResponseEntity<PageResponse<UserResponse>> findAll(
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable) {
Page<UserResponse> page = userService.findAll(pageable);
return ResponseEntity.ok(PageResponse.from(page));
}
// ── JSON output ───────────────────────────────────────────────────────
// {
// "content": [...],
// "page": 0,
// "size": 20,
// "totalElements": 243,
// "totalPages": 13,
// "first": true,
// "last": false
// }Cursor-Based Pagination
Offset pagination (page/size) degrades on large tables because the database must scan and discard all preceding rows. Cursor-based pagination uses the last-seen record's ID or timestamp as a seek point, producing constant-time queries regardless of depth. It trades random access for stable, efficient sequential navigation.
Java
// ── Repository — keyset / seek pagination ────────────────────────────
public interface UserRepository extends JpaRepository<User, Long> {
// Seek forward from the last seen id
@Query("""
SELECT u FROM User u
WHERE (:cursor IS NULL OR u.id > :cursor)
ORDER BY u.id ASC
LIMIT :size
""")
List<User> findNextPage(
@Param("cursor") Long cursor,
@Param("size") int size);
}
// ── Cursor response envelope ───────────────────────────────────────────
public record CursorPage<T>(
List<T> content,
String nextCursor, // opaque token — clients treat as black box
boolean hasMore
) {}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo;
public CursorPage<UserResponse> findPage(String encodedCursor, int size) {
Long cursor = decodeCursor(encodedCursor);
int fetch = size + 1; // fetch one extra to detect hasMore
List<User> rows = userRepo.findNextPage(cursor, fetch);
boolean hasMore = rows.size() == fetch;
List<UserResponse> content = rows.stream()
.limit(size)
.map(UserResponse::from)
.toList();
String nextCursor = hasMore
? encodeCursor(rows.get(size - 1).getId())
: null;
return new CursorPage<>(content, nextCursor, hasMore);
}
private Long decodeCursor(String token) {
if (token == null) return null;
return Long.parseLong(
new String(Base64.getDecoder().decode(token)));
}
private String encodeCursor(Long id) {
return Base64.getEncoder()
.encodeToString(String.valueOf(id).getBytes());
}
}
// ── Controller ────────────────────────────────────────────────────────
@GetMapping("/cursor")
public ResponseEntity<CursorPage<UserResponse>> findByCursor(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Max(100) int size) {
return ResponseEntity.ok(userService.findPage(cursor, size));
}Pagination Performance
Deep offset pagination is slow — OFFSET 10000 forces the database to read and discard 10,000 rows before returning your page. For large datasets, apply one of these strategies: cap the maximum page depth, use cursor pagination for sequential access, or use a deferred join to minimise the rows evaluated by the COUNT query.
Java
// ── 1. Cap maximum page depth ─────────────────────────────────────────
@GetMapping
public ResponseEntity<PageResponse<UserResponse>> findAll(
@PageableDefault(size = 20) Pageable pageable) {
int maxPage = 500; // refuse requests beyond page 500
if (pageable.getPageNumber() > maxPage) {
throw new BadRequestException(
"Page number must not exceed " + maxPage);
}
return ResponseEntity.ok(
PageResponse.from(userService.findAll(pageable)));
}
// ── 2. Deferred join — avoids loading full rows for the COUNT ─────────
@Query(value = """
SELECT u.* FROM users u
INNER JOIN (
SELECT id FROM users
ORDER BY created_at DESC
LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}
) ids ON u.id = ids.id
""",
countQuery = "SELECT COUNT(*) FROM users",
nativeQuery = true)
Page<User> findAllDeferred(Pageable pageable);
// ── 3. Slice<T> — skip the COUNT query entirely ───────────────────────
// Use when total count is not needed (infinite scroll, "load more" UI)
public interface UserRepository extends JpaRepository<User, Long> {
Slice<User> findByStatus(UserStatus status, Pageable pageable);
}
// Slice has: content, hasNext(), hasPrevious() — but NO totalElements
@GetMapping("/scroll")
public ResponseEntity<SliceResponse<UserResponse>> scroll(
@PageableDefault(size = 20) Pageable pageable) {
Slice<User> slice = userRepo.findByStatus(UserStatus.ACTIVE, pageable);
return ResponseEntity.ok(SliceResponse.from(
slice.map(UserResponse::from)));
}