Unbounded queries and incorrect pagination destroy Spring Boot performance at scale. Learn how to implement pagination correctly with Spring Data, offset vs cursor-based, and the count query trap.
JOptimize Team
Pagination is one of those features developers add in 10 minutes and spend weeks fixing. The default Spring Data implementation works correctly - but at scale, its limitations cause full-table scans, unbounded memory usage, and response times that grow with your data set.
@Repository public interface OrderRepository extends JpaRepository<Order, Long> { Page<Order> findByCustomerId(Long customerId, Pageable pageable); } @RestController public class OrderController { @GetMapping("/orders") public Page<OrderDto> getOrders( @RequestParam Long customerId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "createdAt,desc") String sort) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); return orderRepository.findByCustomerId(customerId, pageable) .map(orderMapper::toDto); } }
This works. But there are several traps hiding in this simple code.
Page<T> fires TWO queries: one for the data, one for the total count. The count query can be expensive:
-- Data query SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET 0 -- Count query - full table scan if no index SELECT COUNT(*) FROM orders WHERE customer_id = ?
For tables with millions of rows, COUNT(*) with joins can take seconds. Fix it by:
Option A: Use Slice instead of Page (no count query)
// Slice doesn't fire a count query - returns hasNext() instead of total Slice<Order> findByCustomerId(Long customerId, Pageable pageable); // Client gets: content, hasNext, page number - but NOT total count // Good for infinite scroll, bad for showing "Page 3 of 47"
Option B: Separate the count query
@Query(value = "SELECT o FROM Order o JOIN FETCH o.items WHERE o.customerId = :cid", countQuery = "SELECT COUNT(o) FROM Order o WHERE o.customerId = :cid") Page<Order> findByCustomerIdWithItems(@Param("cid") Long customerId, Pageable pageable);
The count query avoids the JOIN FETCH, making it much faster.
Offset-based pagination (LIMIT 20 OFFSET 10000) gets slower as the offset increases:
-- Page 1: fast SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0 -- Page 500: database scans 10,000 rows to skip them SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000
For large datasets, use keyset (cursor) pagination:
// Instead of: GET /orders?page=500&size=20 // Use: GET /orders?after=2024-01-15T10:30:00Z&afterId=12345&size=20 @Query("SELECT o FROM Order o WHERE " + "(o.createdAt < :cursor OR (o.createdAt = :cursor AND o.id < :cursorId)) " + "ORDER BY o.createdAt DESC, o.id DESC") List<Order> findBeforeCursor( @Param("cursor") LocalDateTime cursor, @Param("cursorId") Long cursorId, Pageable pageable);
The cursor query always hits an index and takes the same time regardless of page depth.
// DANGEROUS - client can request size=1000000 @GetMapping("/orders") public Page<Order> getOrders(Pageable pageable) { return orderRepository.findAll(pageable); }
Always cap page size:
@GetMapping("/orders") public Page<OrderDto> getOrders( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { int cappedSize = Math.min(size, 100); // Never more than 100 Pageable pageable = PageRequest.of(page, cappedSize); return orderRepository.findAll(pageable).map(orderMapper::toDto); }
Or use Spring Data's PageableHandlerMethodArgumentResolverCustomizer:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver(); resolver.setMaxPageSize(100); // Hard cap resolver.setFallbackPageable(PageRequest.of(0, 20)); // Default resolvers.add(resolver); } }
// Sorting by 'customerName' - a VARCHAR column with no index PageRequest.of(0, 20, Sort.by("customerName")); // ? Full table scan + filesort - catastrophic on large tables
Only allow sorting on indexed columns:
public Page<OrderDto> getOrders(String sortBy, String direction, Pageable pageable) { Set<String> allowedSortFields = Set.of("createdAt", "total", "status"); if (!allowedSortFields.contains(sortBy)) { throw new InvalidSortFieldException("Invalid sort field: " + sortBy); } Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); return orderRepository.findAll(PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), sort) ).map(orderMapper::toDto); }
List<T> from API endpoints with no pagination - a list endpoint that returns all records will eventually OOM your serverPage<Entity> directly in REST responses - serializes internal Spring Data fields; always map to a DTOORDER BY column causes full table scansSpring Data pagination is correct but has performance traps at scale: use Slice instead of Page for infinite scroll (no count query), use keyset pagination for deep page navigation, cap page size server-side, and only sort on indexed columns. These four changes cover the vast majority of pagination performance problems.
JOptimize flags unbounded findAll() calls, missing page size caps, and sorting on likely non-indexed columns across your Spring Boot repositories.
Fix unbounded queries before they cause production memory issues - free scan, no configuration required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.