Pagination and Filtering in Spring GraphQL - Handling Large Datasets

Returning thousands of records in a single query is a recipe for disaster. Learn cursor-based pagination, filtering, and sorting patterns in Spring GraphQL.
Why Pagination Matters
Imagine this query on a production database:
query AllUsers {
users { # Returns 1,000,000 users!
id
name
email
}
}
Without pagination:
- Memory explosion on the server
- Network timeout transferring megabytes of JSON
- Client crash trying to render millions of items
GraphQL supports two pagination styles: offset-based and cursor-based.
Offset-Based Pagination
The simpler approach, familiar from REST APIs:
Schema Definition
type Query {
books(page: Int = 0, size: Int = 20): BookPage!
}
type BookPage {
content: [Book!]!
totalElements: Int!
totalPages: Int!
number: Int!
size: Int!
hasNext: Boolean!
hasPrevious: Boolean!
}
type Book {
id: ID!
title: String!
author: Author!
}
Implementation with Spring Data
@Controller
public class BookController {
private final BookRepository bookRepository;
@QueryMapping
public BookPage books(@Argument int page, @Argument int size) {
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
Page<Book> bookPage = bookRepository.findAll(pageable);
return new BookPage(
bookPage.getContent(),
bookPage.getTotalElements(),
bookPage.getTotalPages(),
bookPage.getNumber(),
bookPage.getSize(),
bookPage.hasNext(),
bookPage.hasPrevious()
);
}
}
Client Usage
# Page 1
query BooksPageOne {
books(page: 0, size: 10) {
content { id title }
totalPages
hasNext
}
}
# Page 2
query BooksPageTwo {
books(page: 1, size: 10) {
content { id title }
hasNext
}
}
Pros and Cons
| Pros | Cons |
|---|---|
| Simple to implement | Inconsistent with changing data |
| Familiar to REST developers | OFFSET is slow on large tables |
| Can jump to any page | Items can shift between pages |
Cursor-Based Pagination (Relay Style)
The GraphQL community standardized on Relay-style pagination using connections and cursors.
Since Spring for GraphQL 1.2 (mid-2023), the framework provides Window<T>, ScrollPosition, ScrollSubrange, ConnectionAdapter, and a default ScrollPositionCursorStrategy that Base64-encodes cursors automatically. A controller method that accepts a ScrollSubrange argument and returns a Spring Data Window<Book> (or Slice<Book>) gets the entire connection plumbing for free, including a forward+backward implementation that respects the Relay spec. The hand-rolled BookConnection/BookEdge/PageInfo plus manual cursor encoding shown below is the "do-it-yourself" path; production code using Spring Data should prefer the built-in path. We're walking the manual path here because seeing the pieces makes the abstraction less magical.
Schema Definition
type Query {
books(
first: Int
after: String
last: Int
before: String
): BookConnection!
}
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type BookEdge {
node: Book!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Book {
id: ID!
title: String!
author: Author!
}
Implementation
@Controller
public class BookController {
private final BookRepository bookRepository;
@QueryMapping
public BookConnection books(
@Argument Integer first,
@Argument String after,
@Argument Integer last,
@Argument String before) {
// Default to forward pagination
int limit = first != null ? Math.min(first, 100) : 20;
String cursor = after;
boolean forward = true;
if (last != null && before != null) {
limit = Math.min(last, 100);
cursor = before;
forward = false;
}
// Decode cursor
String decodedCursor = cursor != null ? decodeCursor(cursor) : null;
// Query with cursor
List<Book> books = forward
? bookRepository.findBooksAfter(decodedCursor, limit + 1)
: bookRepository.findBooksBefore(decodedCursor, limit + 1);
// Check if there are more pages
boolean hasMore = books.size() > limit;
if (hasMore) {
books = books.subList(0, limit);
}
if (!forward) {
Collections.reverse(books);
}
// Build edges with cursors
List<BookEdge> edges = books.stream()
.map(book -> new BookEdge(book, encodeCursor(book.id())))
.toList();
// Build page info
PageInfo pageInfo = new PageInfo(
forward ? hasMore : decodedCursor != null,
forward ? decodedCursor != null : hasMore,
edges.isEmpty() ? null : edges.get(0).cursor(),
edges.isEmpty() ? null : edges.get(edges.size() - 1).cursor()
);
long totalCount = bookRepository.count();
return new BookConnection(edges, pageInfo, totalCount);
}
private String encodeCursor(String id) {
return Base64.getEncoder().encodeToString(("cursor:" + id).getBytes());
}
private String decodeCursor(String cursor) {
String decoded = new String(Base64.getDecoder().decode(cursor));
return decoded.replace("cursor:", "");
}
}
Repository Methods
@Repository
public interface BookRepository extends JpaRepository<Book, String> {
@Query("SELECT b FROM Book b WHERE b.id > :cursor ORDER BY b.id ASC")
List<Book> findBooksAfter(@Param("cursor") String cursor, Pageable pageable);
@Query("SELECT b FROM Book b WHERE b.id < :cursor ORDER BY b.id DESC")
List<Book> findBooksBefore(@Param("cursor") String cursor, Pageable pageable);
default List<Book> findBooksAfter(String cursor, int limit) {
if (cursor == null) {
return findAll(PageRequest.of(0, limit, Sort.by("id"))).getContent();
}
return findBooksAfter(cursor, PageRequest.of(0, limit));
}
default List<Book> findBooksBefore(String cursor, int limit) {
return findBooksBefore(cursor, PageRequest.of(0, limit));
}
}
Client Usage
# First page
query BooksFirstPage {
books(first: 10) {
edges {
node { id title }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Next page (using endCursor from previous response)
query BooksNextPage {
books(first: 10, after: "Y3Vyc29yOjEw") {
edges {
node { id title }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Adding Filters
Combine pagination with filtering:
Schema
type Query {
books(
first: Int
after: String
filter: BookFilter
): BookConnection!
}
input BookFilter {
title: String
genre: String
publishedAfter: Int
publishedBefore: Int
authorName: String
}
Implementation with Specifications
@Controller
public class BookController {
@QueryMapping
public BookConnection books(
@Argument Integer first,
@Argument String after,
@Argument BookFilter filter) {
Specification<Book> spec = buildSpecification(filter);
// Add cursor condition to spec
if (after != null) {
String cursorId = decodeCursor(after);
spec = spec.and((root, query, cb) ->
cb.greaterThan(root.get("id"), cursorId));
}
int limit = first != null ? Math.min(first, 100) : 20;
List<Book> books = bookRepository.findAll(spec,
PageRequest.of(0, limit + 1, Sort.by("id"))).getContent();
// ... rest of pagination logic
}
private Specification<Book> buildSpecification(BookFilter filter) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (filter != null) {
if (filter.title() != null) {
predicates.add(cb.like(
cb.lower(root.get("title")),
"%" + filter.title().toLowerCase() + "%"
));
}
if (filter.genre() != null) {
predicates.add(cb.equal(root.get("genre"), filter.genre()));
}
if (filter.publishedAfter() != null) {
predicates.add(cb.greaterThan(
root.get("publishedYear"),
filter.publishedAfter()
));
}
if (filter.publishedBefore() != null) {
predicates.add(cb.lessThan(
root.get("publishedYear"),
filter.publishedBefore()
));
}
if (filter.authorName() != null) {
Join<Book, Author> author = root.join("author");
predicates.add(cb.like(
cb.lower(author.get("name")),
"%" + filter.authorName().toLowerCase() + "%"
));
}
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
Client Query with Filter
query FilteredBooks {
books(
first: 10
filter: {
genre: "Fiction"
publishedAfter: 1950
authorName: "Orwell"
}
) {
edges {
node {
title
publishedYear
author { name }
}
}
totalCount
pageInfo { hasNextPage endCursor }
}
}
Adding Sorting
Everything above encodes the book's id in the cursor and pages with
WHERE id > :cursor ORDER BY id. That is correct exactly as long as id is what
you order by. Add a sortBy: TITLE argument and keep the id-based cursor, and the
pagination quietly breaks: the WHERE clause walks the id order while the
ORDER BY walks the title order, so pages overlap, skip rows, and never
terminate cleanly. Nothing errors. You just serve wrong pages.
A keyset cursor has to carry every column in the sort, plus a unique tiebreaker:
// Sorting by title: the cursor carries the title AND the id.
private String encodeCursor(Book book) {
return Base64.getUrlEncoder().encodeToString(
(book.title() + "\u0000" + book.id()).getBytes(StandardCharsets.UTF_8));
}
and the predicate has to compare them as a tuple, which in JPQL means spelling out the comparison:
WHERE (b.title > :title) OR (b.title = :title AND b.id > :id)
ORDER BY b.title ASC, b.id ASC
The tiebreaker is what makes the order total. Two books sharing a title would otherwise do not have a defined position relative to each other, and the same row can appear on two consecutive pages.
This is the point where Spring Data's Window and ScrollPosition start paying
for themselves: ScrollPosition.forward(keys) holds a multi-column keyset and the
framework builds the predicate for you. If your sort is user-controllable, use
the built-in support instead of maintaining the tuple comparison by hand.
type Query {
books(
first: Int
after: String
filter: BookFilter
orderBy: BookOrderBy
): BookConnection!
}
input BookOrderBy {
field: BookSortField!
direction: SortDirection = ASC
}
enum BookSortField {
TITLE
PUBLISHED_YEAR
CREATED_AT
}
enum SortDirection {
ASC
DESC
}
Implementation
@QueryMapping
public BookConnection books(
@Argument Integer first,
@Argument String after,
@Argument BookFilter filter,
@Argument BookOrderBy orderBy) {
Sort sort = buildSort(orderBy);
Specification<Book> spec = buildSpecification(filter);
// Cursor-based pagination with custom sort
if (after != null) {
CursorData cursor = decodeCursor(after);
spec = spec.and(buildCursorSpec(cursor, orderBy));
}
// ... rest of implementation
}
private Sort buildSort(BookOrderBy orderBy) {
if (orderBy == null) {
return Sort.by("id");
}
String field = switch (orderBy.field()) {
case TITLE -> "title";
case PUBLISHED_YEAR -> "publishedYear";
case CREATED_AT -> "createdAt";
};
return orderBy.direction() == SortDirection.DESC
? Sort.by(field).descending().and(Sort.by("id").descending())
: Sort.by(field).ascending().and(Sort.by("id").ascending());
}
Efficient Total Count
Counting all records can be expensive. Options:
1. Lazy Count
Only count when requested:
@Controller
public class BookConnectionController {
@SchemaMapping(typeName = "BookConnection", field = "totalCount")
public long totalCount(BookConnection connection, DataFetchingEnvironment env) {
// Only execute if client requests totalCount
return bookRepository.count(connection.getSpecification());
}
}
2. Estimated Count
Use database statistics:
@Query(value = "SELECT reltuples::bigint FROM pg_class WHERE relname = 'books'",
nativeQuery = true)
long estimatedCount();
3. Cached Count
Cache the count:
@Cacheable(value = "bookCount", key = "#filter.hashCode()")
public long countBooks(BookFilter filter) {
return bookRepository.count(buildSpecification(filter));
}
Search with Full-Text
For complex search, use full-text search:
type Query {
searchBooks(
query: String!
first: Int
after: String
): BookConnection!
}
@QueryMapping
public BookConnection searchBooks(
@Argument String query,
@Argument Integer first,
@Argument String after) {
// Using Elasticsearch or PostgreSQL full-text search
SearchHits<Book> hits = elasticsearchOperations.search(
NativeQuery.builder()
.withQuery(q -> q.multiMatch(m -> m
.query(query)
.fields("title^2", "description", "author.name")
))
.withPageable(PageRequest.of(0, first != null ? first : 20))
.build(),
Book.class
);
// Convert to connection
// ...
}
Performance Tips
1. Use Indexes
-- For cursor pagination
CREATE INDEX idx_books_id ON books(id);
-- For filtered queries
CREATE INDEX idx_books_genre ON books(genre);
CREATE INDEX idx_books_published_year ON books(published_year);
-- For sorting
CREATE INDEX idx_books_created_at ON books(created_at DESC);
-- Composite index for common filter + sort
CREATE INDEX idx_books_genre_year ON books(genre, published_year DESC);
2. Limit Maximum Page Size
int limit = Math.min(requestedSize, 100); // Never return more than 100
3. Use Projections
Only select needed columns:
@Query("SELECT new BookSummary(b.id, b.title) FROM Book b WHERE ...")
List<BookSummary> findSummaries(Specification<Book> spec, Pageable pageable);
4. Batch Load Related Data
Don't forget DataLoader for relationships:
@BatchMapping
public Map<Book, Author> author(List<Book> books) {
// Single query for all authors
// ...
}
Complete Example
Here's a production-ready pagination setup:
type Query {
books(
first: Int = 20
after: String
last: Int
before: String
filter: BookFilter
orderBy: [BookOrderBy!]
): BookConnection!
}
input BookFilter {
search: String
genres: [String!]
publishedBetween: DateRange
authorIds: [ID!]
}
input DateRange {
from: Int
to: Int
}
input BookOrderBy {
field: BookSortField!
direction: SortDirection = ASC
}
enum BookSortField {
TITLE
PUBLISHED_YEAR
RATING
CREATED_AT
}
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
totalCount: Int!
facets: BookFacets
}
type BookFacets {
genres: [FacetCount!]!
yearRange: IntRange!
}
type FacetCount {
value: String!
count: Int!
}
Summary
| Pagination Style | Best For |
|---|---|
| Offset | Small datasets, admin panels |
| Cursor | Large datasets, infinite scroll |
| Feature | Implementation |
|---|---|
| Pagination | BookConnection with PageInfo |
| Filtering | Specification<T> pattern |
| Sorting | Sort + stable secondary sort |
| Total count | Lazy loading or estimation |
Well-designed pagination makes your API scalable and pleasant to use. Start with cursor-based pagination and add filtering gradually based on client needs.
Next: Schema Design Patterns - organizing large GraphQL schemas in Spring.
You have reached the last page of this post. hasNextPage is false, and the cursor is at the bottom of your scrollbar.