Spring Data JPA Pagination: Page vs Slice vs Keyset Compared
Comparison of Page, Slice and keyset pagination in Spring Data JPA with implementation sketches and verification tips for large tables.
18 Feb 2026, 17:54 UTC

Decision and Constraints
When paginating large tables in Spring Data JPA, the choice of pagination API directly impacts query cost and UI responsiveness. The Page interface bundles content with total count and total pages, but that convenience comes at the price of an additional SELECT COUNT(*) query per request. On tables with millions of rows, that count query can dominate request latency. Offset-based pagination worsens the problem: the database must scan and discard all skipped rows, so page 10,000 takes noticeably longer than page 1. This survey of the three built-in Spring Data JPA pagination strategies compares their trade-offs and provides concrete implementation sketches you can adapt today.
Overview of the Three Options
Spring Data JPA provides three built-in pagination mechanisms for repository methods that accept a Pageable argument:
Page<T>: returns the content, total element count, and total page count. The total count is obtained by a separateSELECT COUNT(*)query.Slice<T>: returns the content plus ahasNextboolean indicating whether more elements exist. No count query is executed.- List with a manually applied
Pageable: returns only the content, with no pagination metadata.
Comparison of Pagination Options
| Option | Returned Data | SELECT COUNT(*) | Deep Offset Behavior | Random Page Access |
|---|---|---|---|---|
Page<T> | Content + total count + total pages | Yes, separate count query | Latency grows linearly with page number | Yes |
Slice<T> | Content + hasNext flag only | No count query emitted | Scans skipped rows, no count overhead | Yes |
| List + manual Pageable | Content only, no metadata | None | Same offset scanning | Yes |
Trade-offs
- Page: full metadata and random page access, but every request incurs an extra count query that becomes expensive as tables grow. Use when you need total page numbers and tables are modest in size (up to a few hundred thousand rows).
- Slice: eliminates the count query, making it suitable for “load more” interfaces where total pages aren’t required. Deep offsets still scan skipped rows, so latency still grows with page number, but the overhead is limited to the data fetch rather than an additional count query.
- Keyset (seek) pagination: replaces offset skipping with a cursor filter on a stable, indexed column (e.g.,
WHERE id < :lastId), giving near-constant query time regardless of position. The trade-off is losing random page access and the ability to compute total pages; it also requires a unique sort column or a reliable tiebreaker (typically the primary key).
Concrete Implementation: Slice for “Load More” UI
// Repository method returning a Slice
public interface OrderRepository extends JpaRepository {
Slice findByStatus(String status, Pageable p);
}
// Service-level usage
Slice slice = orderRepository.findByStatus(
"shipped",
PageRequest.of(0, 20, Sort.by("id").descending())
);
boolean hasMore = slice.hasNext();
List page = slice.getContent();
Concrete Implementation: Keyset (Seek) Pagination
// Repository method for keyset pagination
public interface OrderRepository extends JpaRepository {
@Query("SELECT o FROM Order o WHERE o.id < :lastId ORDER BY o.id DESC")
List findByIdLessThan(@Param("lastId") Long lastId, Pageable p);
}
// Client-side: echo the last seen id as the cursor
List page = orderRepository.findByIdLessThan(
lastSeenId,
PageRequest.of(0, 20, Sort.by("id").descending())
);
Because the sort key (id) is the primary key, it is unique. If your sort column can contain duplicates, always include the primary key as a tiebreaker in the Order clause to prevent rows from being skipped or repeated across pages.
Practical Verification
- Enable SQL logging by adding
spring.jpa.show-sql=truetosrc/main/resources/application.propertiesor by configuring anSLF4Jlogger fororg.hibernate.SQL. Run a paginated request and inspect the console output:Pagequeries should include aSELECT COUNT(*)before the data query, whileSlicequeries should emit only the data query. - Benchmark deep offsets: seed a table with several hundred thousand rows. Query page 1 and page 10,000 with identical
PageRequest.of(page, size)and compare latency. Offset pagination will show increasing request time;Sliceor keyset queries will not. - Test sort stability: insert rows where the sort column has duplicate values. Run paginated queries with and without a unique tiebreaker (primary key in the
Orderclause). Assert that no row is skipped or repeated across pages when the tiebreaker is present.
Version considerations
Spring Data JPA 3.x modified some count-query and pagination behaviors compared to 2.x. For example, the handling of SELECT COUNT(*) in derived query contexts changed. Verify the pagination behavior against the version in your project, especially if you rely on total count semantics or hasNext edge cases.
Choosing the right strategy
- Use
Pagewhen you need total page numbers and tables are modest in size. - Use
Slicewhen you need to know only whether another page exists (infinite‑scroll or “load more”) and want to avoid the count query overhead. - Use keyset pagination when you have large, frequently accessed tables and deep page navigation is common. Accept the loss of random page access and total count in exchange for consistent query latency.
Rate-limiting page size
Never trust client-supplied page size without a server-side cap. A malicious or accidental huge LIMIT can cause denial‑of‑service. Apply a bound such as int size = Math.min(requestedSize, 100) before constructing the PageRequest.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.