Offset-based vs. Keyset pagination for high-volume Hibernate datasets
0 reputation · 19 Dec 2021, 11:59 UTC
0 reputation · 19 Dec 2021, 11:59 UTC
When implementing pagination in Hibernate ORM for tables containing millions of records, there is a design trade-off between using native offset-based navigation and manual keyset pagination.
Offset-based pagination via setFirstResult() and setMaxResults() allows users to jump to arbitrary page numbers. However, this approach often leads to linear performance degradation and potential full table scans as the offset increases. In contrast, keyset pagination (the seek method) maintains constant time complexity by filtering based on the last seen unique identifier, but it restricts navigation to sequential movement.
Given a requirement for consistent response times regardless of page depth, the choice between these two methods impacts both the API design and the database execution plan.
31775 reputation · 19 Dec 2021, 15:05 UTC
For datasets with millions of records where consistent response times are required regardless of page depth, keyset pagination (the seek method) is the only sustainable approach. Offset-based pagination suffers from linear performance degradation because the database must scan and discard all preceding rows to reach the requested offset.
The requirement for arbitrary page jumping (e.g., "Jump to Page 500") is incompatible with keyset pagination. Because the seek method relies on the value of the last seen record to find the next set, it cannot calculate the starting point of a distant page without first traversing all intermediate records.
If arbitrary jumping is a hard business requirement, you must either accept the performance penalty of setFirstResult() or implement a hybrid approach, such as caching page-to-key mappings for common intervals.
While offset pagination uses Hibernate's built-in setFirstResult(int) and setMaxResults(int), keyset pagination is implemented via a WHERE clause filtering on a unique, monotonic column (usually the Primary Key).
Example Keyset Logic (JPQL):
SELECT e FROM Entity e WHERE e.id > :lastSeenId ORDER BY e.id ASC
Long id).WHERE clause to seek directly to the next record.setMaxResults() to define the page size.To confirm the behavior in your environment, you can use the following verification steps:
EXPLAIN ANALYZE on the generated SQL. Offset pagination will typically show an Index Scan or Seq Scan that reads up to the offset; keyset pagination should show an Index Seek.hibernate.generate_statistics to monitor the time spent on query execution as you increase the page depth.Missing Diagnostic: Does your dataset require sorting by a non-unique column (e.g., created_at)? If so, you must use a composite key (created_at, id) to prevent skipping records with identical timestamps.
Use comments to ask for clarification. Post a solution as an answer.
31,775 reputation · 19 Dec 2021, 22:13 UTC
One clarification worth adding: since Hibernate 6.4, you don't have to hand-roll keyset logic in JPQL. The KeyedPage and KeyedResultList APIs handle cursor construction for you, including multi-column sort keys:
KeyedResultList<Entity> page = session.createQuery(
"from Entity e order by e.created, e.id", Entity.class)
.setPage(Page.first(50))
.getKeyedResultList();
Page next = page.getNextPage(); // pass to setPage() for the following request
This matters because the single-column WHERE id > :lastSeenId pattern shown above breaks down as soon as you sort by a non-unique column — you then need tuple comparison ((created, id) > (?, ?)) to avoid skipped or duplicated rows, and the built-in API generates that correctly. If you're on an older Hibernate version, verify with EXPLAIN that your hand-written cursor predicate actually produces an index range scan rather than a filter over a sort.