Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Spring API pagination: bounded requests and immutable snapshots

Last updated: 29 Sept 20264 min read
tutorial
IntermediateBy AITrove Editorial

Pagination divides a collection response into bounded portions, but the chosen offset and ordering rules determine whether repeated requests describe a consistent view.

Download Spring source kit

This lesson uses the downloadable source kit: Java 21, Spring Boot 4.0.8 and its managed Spring Framework 7 dependencies. The version is pinned for repeatable builds.

Bound the accepted request

The receipt store accepts non-negative offset and limits from one to one hundred. An offset beyond the collection returns an empty page. The controller test rejects a requested size of 101 with 400, proving that the bound is enforced rather than merely suggested in documentation.

The source copies receipts in insertion order and slices the copy using half-open endpoints. The returned immutable list prevents a caller from clearing the service’s response container, and the service lock covers construction of that snapshot.

Database pagination has different costs

This small in-memory fixture copies the whole collection for each page. It is intentionally inspectable, not suitable for an unbounded production ledger. A database implementation should use an explicit stable sort and bounded query rather than load all rows before slicing.

Offset pages can drift when concurrent insertions or deletions move row positions. Keyset pagination with a stable unique ordering can address a different workload, but needs its own cursor contract. The kit does not pretend to implement that cursor by renaming offset.

Checked source

Java
package in.aitrove.learning;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class ReceiptStore {
    public record Receipt(long id, int amountMinor) {}
    private final Map<Long,Receipt> receipts = new LinkedHashMap<>();
    private long nextId = 1;
    public synchronized Receipt create(int amountMinor) {
        if (amountMinor <= 0) throw new IllegalArgumentException("amount");
        Receipt receipt = new Receipt(nextId++, amountMinor); receipts.put(receipt.id(), receipt); return receipt;
    }
    public synchronized Optional<Receipt> find(long id) { return Optional.ofNullable(receipts.get(id)); }
    public synchronized List<Receipt> page(int offset, int limit) {
        if (offset < 0 || limit < 1 || limit > 100) throw new IllegalArgumentException("page");
        List<Receipt> snapshot = new ArrayList<>(receipts.values());
        int start = Math.min(offset, snapshot.size());
        return List.copyOf(snapshot.subList(start, start + Math.min(limit, snapshot.size() - start)));
    }
}

Test the boundary

Run mvn test in the source-kit directory. ReceiptBoundaryTest.emptyPageOutsideRangeIsEmpty and returnedPageCannotMutateStore checks the behavior described here. Java excerpts belong to the named source-kit classes; they are not independent source files unless the complete class is shown.

Costs and boundaries

This implementation is O(n) time and O(n) snapshot storage per page, even when the response contains at most one hundred rows. A short response does not imply a cheap query.

Common Mistakes

  • Bound offset and limit inputs.
  • Do not load an unbounded database table to create one page.
  • Do not claim snapshot consistency across separate page requests.

Read next

Jdbc template, Spring MVC request validation: reject invalid commands before mutation, Java collection views: live wrappers, snapshots and shallow copies.

Extend the tested workflow

Continue with Spring Data keyset pagination: continue after the last stable identifier.

spring
spring-boot
pagination-bounds
Storage details