Keyset Pagination with Spring Data WindowIterator
Are you struggling with performance issues in your Spring, Jakarta EE, or Java EE application?
What if there were a tool that could automatically detect what caused performance issues in your JPA and Hibernate data access layer?
Wouldn’t it be awesome to have such a tool to watch your application and prevent performance issues during development, long before they affect production systems?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, Micronaut, or Play Framework.
So, rather than fixing performance issues in your production system on a Saturday night, you are better off using Hypersistence Optimizer to help you prevent those issues so that you can spend your time on the things that you love!
Introduction
In this article, we are going to see how we can generate Keyset Pagination queries with the Spring Data WindowIterator utility.
This is an alternative to the Blaze Persistence solution I documented in this article.
Domain Model
Considering we have the following PostComment entity that has both a createdOn and a monotonically increasing id:
@Entity
@Table(name = "post_comment")
public class PostComment {
⠀
@Id
private Long id;
⠀
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
⠀
@ManyToOne(fetch = FetchType.LAZY)
private PostComment parent;
⠀
private String review;
⠀
@Enumerated(EnumType.ORDINAL)
private Status status;
⠀
@Column(name = "created_on")
private LocalDateTime createdOn;
⠀
private int votes;
}
As I explained in this article, to create the Keyset Pagination queries, we are going to need the created_on and id columns since we need to apply the pagination on the time-sorted post_comment records.
Streaming with the Spring Data WindowIterator utility
To use the Keyset Pagination with Spring Data, we need to create a Spring Data JPA method that returns a Window over the query result set, as illustrated by the following findByPost method in the PostCommentRepository:
@Repository
public interface PostCommentRepository
extends JpaRepository<PostComment, Long> {
⠀
Window<PostComment> findByPost(
Post post,
Pageable pageable,
ScrollPosition position
);
}
Next, to iterate over the Window returned by the findByPost method call using the Keyset Pagination strategy, we can use the Spring Data WindowIterator utility:
WindowIterator<PostComment> commentWindowIterator = WindowIterator
.of(position ->
postCommentRepository.findByPost(
post,
PageRequest.of(
0,
pageSize,
Sort.by(
Sort.Order.desc(PostComment_.CREATED_ON),
Sort.Order.desc(PostComment_.ID)
)
),
position
)
)
.startingAt(ScrollPosition.keyset());
The Spring WindowIterator and Window classes are associated as follows:

The WindowIterator allows us to choose between the standard SQL OFFSET or the Keyset Pagination strategies. The Keyset Pagination requires the position of the last element on a previous page to be used as the offset for the next page that has to be fetched, and that’s the reason we are passing the position reference to the findByPost method call.
Testing Time
Assuming we have created 30 PostComment entities:
int POST_COMMENT_COUNT = 30;
LocalDateTime timestamp = LocalDateTime.of(
2024, 9, 26, 4, 0, 0, 0
);
long commentId = 1;
Post post = new Post()
.setId(1L)
.setTitle("Post nr. 1");
for (long i = 1; i <= POST_COMMENT_COUNT; i++) {
post.addComment(
new PostComment()
.setId(commentId++)
.setReview(String.format("Awesome post %d", i))
.setStatus(PostComment.Status.PENDING)
.setCreatedOn(timestamp.plusHours(commentId))
.setVotes((int) (i % 7))
);
}
When streaming over the Spring Data WindowIterator:
Post post = postRepository.getReferenceById(1L);
int pageSize = 10;
WindowIterator<PostComment> commentWindowIterator = WindowIterator.of(
position -> postCommentRepository.findByPost(
post,
PageRequest.of(
0,
pageSize,
Sort.by(
Sort.Order.desc(PostComment_.CREATED_ON),
Sort.Order.desc(PostComment_.ID)
)
),
position
)
).startingAt(ScrollPosition.keyset());
commentWindowIterator.forEachRemaining(
comment -> LOGGER.info(
"Post comment {} created at {}",
comment.getReview(),
comment.getCreatedOn()
)
);
Spring Data JPA generates the following log entries:
SELECT
pc.id, pc.created_on, pc.parent_id,
pc.post_id, pc.review, pc.status, pc.votes
FROM
post_comment pc
WHERE
pc.post_id = 1
ORDER BY
pc.created_on DESC, pc.id DESC
OFFSET 0 ROWS
FETCH FIRST 11 ROWS ONLY
Post comment Awesome post 30 created at 2024-09-27T11:00
Post comment Awesome post 29 created at 2024-09-27T10:00
Post comment Awesome post 28 created at 2024-09-27T09:00
Post comment Awesome post 27 created at 2024-09-27T08:00
Post comment Awesome post 26 created at 2024-09-27T07:00
Post comment Awesome post 25 created at 2024-09-27T06:00
Post comment Awesome post 24 created at 2024-09-27T05:00
Post comment Awesome post 23 created at 2024-09-27T04:00
Post comment Awesome post 22 created at 2024-09-27T03:00
Post comment Awesome post 21 created at 2024-09-27T02:00
SELECT
pc.id, pc.created_on, pc.parent_id,
pc.post_id, pc.review, pc.status, pc.votes
FROM
post_comment pc
WHERE
pc.post_id = 1 AND
(
pc.created_on < '2024-09-27 02:00:00.0' OR
pc.created_on = '2024-09-27 02:00:00.0' AND
pc.id < 21
)
ORDER BY
pc.created_on DESC, pc.id DESC
OFFSET 0 ROWS
FETCH FIRST 11 ROWS ONLY
Post comment Awesome post 20 created at 2024-09-27T01:00
Post comment Awesome post 19 created at 2024-09-27T00:00
Post comment Awesome post 18 created at 2024-09-26T23:00
Post comment Awesome post 17 created at 2024-09-26T22:00
Post comment Awesome post 16 created at 2024-09-26T21:00
Post comment Awesome post 15 created at 2024-09-26T20:00
Post comment Awesome post 14 created at 2024-09-26T19:00
Post comment Awesome post 13 created at 2024-09-26T18:00
Post comment Awesome post 12 created at 2024-09-26T17:00
Post comment Awesome post 11 created at 2024-09-26T16:00
SELECT
pc.id, pc.created_on, pc.parent_id,
pc.post_id, pc.review, pc.status, pc.votes
FROM
post_comment pc
WHERE
pc.post_id = 1 AND
(
pc.created_on < '2024-09-26 16:00:00.0' OR
pc.created_on = '2024-09-26 16:00:00.0' AND
pc.id < 11
)
ORDER BY
pc.created_on DESC, pc.id DESC
OFFSET 0 ROWS
FETCH FIRST 11 ROWS ONLY
Post comment Awesome post 10 created at 2024-09-26T15:00
Post comment Awesome post 9 created at 2024-09-26T14:00
Post comment Awesome post 8 created at 2024-09-26T13:00
Post comment Awesome post 7 created at 2024-09-26T12:00
Post comment Awesome post 6 created at 2024-09-26T11:00
Post comment Awesome post 5 created at 2024-09-26T10:00
Post comment Awesome post 4 created at 2024-09-26T09:00
Post comment Awesome post 3 created at 2024-09-26T08:00
Post comment Awesome post 2 created at 2024-09-26T07:00
Post comment Awesome post 1 created at 2024-09-26T06:00
We can see that there are 3 queries since we have 30 records and the page size is 10.
Awesome, right?
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
The Spring Data WindowIterator and Window utilities provide support for fetching records using the Keyset Pagination strategy.
While we could achieve the same result with Blaze Persistence, the advantage of the Spring Data alternative is that we would spare an extra framework dependency to achieve this goal.







Hi Vlad,
Can you share a complete example of how this will work with Spring MVC when we implement a infinite/virtual scroll?
That would be a good idea for a YouTube video tutorial.
How to solve in case of multi threading ingestion if gaps occur e.g. postcomment is inserted with id 12 and createdOn before postcomment with id 11 and createdOn the same as above? Feels that the record with id 11 will be outside any window and get missed that is without reloading a previous window.
For cursor stability, you can use Repeatable Read on PostgreSQL. If you want to see the latest changes when changing the page, then you can use Read Committed.
Thank you for the great article.
Not sure at the moment, but should this work as well with just Spring Data JDBC?
Our current project is not using JPA.
The API is in Spring Data Commons, so theoretically, it should work with other Spring Data projects too. In reality, I’m not sure since I have never used Spring Data JDBC.
For whoever might care, this is NOT supported with Spring Data JDBC at the moment, but the Spring Data team plans to add future support, issue can be tracked here.
https://github.com/spring-projects/spring-data-relational/issues/1605