Skip to main content

Pagination and Filtering in Spring GraphQL - Handling Large Datasets

· 9 min read
GraphQL Guy

Pagination

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

ProsCons
Simple to implementInconsistent with changing data
Familiar to REST developersOFFSET is slow on large tables
Can jump to any pageItems can shift between pages

Cursor-Based Pagination (Relay Style)

The GraphQL community standardized on Relay-style pagination using connections and cursors.

Spring GraphQL ships built-in connection support

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

A cursor has to encode the sort key

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);

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 StyleBest For
OffsetSmall datasets, admin panels
CursorLarge datasets, infinite scroll
FeatureImplementation
PaginationBookConnection with PageInfo
FilteringSpecification<T> pattern
SortingSort + stable secondary sort
Total countLazy 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.