Implementing Keyset Pagination in Hibernate for Large Datasets
Learn how to replace slow offset-based pagination with keyset pagination in Hibernate to maintain constant performance on large datasets and avoid OutOfMemoryErrors.
13 Nov 2025, 20:00 UTC

The Performance Wall of Offset Pagination
When building data-heavy applications with Hibernate, the standard approach to pagination is using setFirstResult(offset) and setMaxResults(limit). While intuitive, this method creates a performance bottleneck as the offset increases. The database must scan and discard all preceding rows to reach the requested starting point, leading to linear performance degradation.
For datasets exceeding a few thousand records, this "offset lag" becomes noticeable. Furthermore, if a record is inserted or deleted between page requests, the user may see duplicate entries or skip items entirely because the absolute position of rows has shifted.
Design Requirements for High-Scale Paging
To maintain constant-time performance regardless of page depth, the system must move from position-based paging to Keyset Pagination (also known as the Seek Method). This requires the following architectural constraints:
- Deterministic Ordering: The result set must be sorted by a unique, non-nullable column (e.g., a primary key or a unique timestamp).
- Stateful Requests: The client must provide the value of the last seen record from the previous page rather than a page number.
- Index Alignment: The sorting column must be indexed to allow the database to jump directly to the next set of rows.
Smallest Suitable Design: The Seek Implementation
Instead of telling the database to "skip 10,000 rows," the query tells the database to "find the first 50 rows where the ID is greater than 10,000." This allows the database to use an index seek operation.
Configuration Example
Assuming a Transaction entity with a primary key id, the implementation shifts from a simple limit/offset to a filtered query. This example uses HQL (Hibernate Query Language) for clarity:
// Traditional Offset (Slow for large offsets)
Query query = session.createQuery("FROM Transaction t ORDER BY t.id ASC");
query.setFirstResult(10000);
query.setMaxResults(50);
// Keyset Pagination (Constant performance)
Long lastSeenId = 10000L; // Passed from the previous page response
Query seekQuery = session.createQuery("FROM Transaction t WHERE t.id > :lastId ORDER BY t.id ASC");
seekQuery.setParameter("lastId", lastSeenId);
seekQuery.setMaxResults(50);
Data and Trust Boundaries
The lastSeenId is provided by the client. To prevent malicious actors from manipulating the query to scrape data or cause denial-of-service via unexpected ranges, the application must:
- Validate that the provided ID conforms to the expected data type.
- Ensure the
setMaxResultsvalue is capped at a hard system limit (e.g., 100) to prevent memory exhaustion. - Avoid using user-supplied sort columns; only allow a predefined set of indexed columns for ordering.
Operational Checks and Memory Management
Fetching large pages can trigger an OutOfMemoryError because Hibernate's First Level Cache (the Session) tracks every managed entity. If you are processing a large page for a background task rather than a UI, you must manage the persistence context.
Diagnostic Check: Use a heap profiler or monitor JVM memory while increasing page sizes. If memory climbs linearly without dropping, the Session is holding onto too many entities.
Mitigation: Periodically call session.clear() or use a StatelessSession for read-only pagination tasks to bypass the first-level cache entirely.
Failure Modes and Design Shifts
| Scenario | Failure Mode | Required Design Change |
|---|---|---|
| Non-unique sort column | Missing records when the "last seen" value is shared by multiple rows. | Implement a composite key (e.g., ORDER BY created_at DESC, id DESC). |
| Requirement for "Jump to Page X" | Keyset pagination cannot jump to arbitrary pages. | Revert to offset pagination for small sets or implement a cached index of page boundaries. |
| Frequent data deletions | The "last seen" ID may no longer exist. | This is handled naturally by the > operator, as it seeks the next available ID. |
Verification of Result
To verify the implementation, run the query through the database's execution plan tool (e.g., EXPLAIN ANALYZE in PostgreSQL). A successful keyset implementation will show an Index Seek or Index Scan starting at the specific ID, whereas offset pagination will show a Full Table Scan or a large Index Scan that discards thousands of rows before returning the result.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.