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 {
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 {
books(page: 0, size: 10) {
content { id title }
totalPages
hasNext
}
}
# Page 2
query {
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:
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.getId())))
.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 {
books(first: 10) {
edges {
node { id title }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Next page (using endCursor from previous response)
query {
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 {
books(
first: 10
filter: {
genre: "Fiction"
publishedAfter: 1950
authorName: "Orwell"
}
) {
edges {
node {
title
publishedYear
author { name }
}
}
totalCount
pageInfo { hasNextPage endCursor }
}
}
Adding Sorting
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.