Skip to main content

Pagination and Filtering in Spring GraphQL - Handling Large Datasets

· 7 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 {
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

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:

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

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.