REST API Patterns
Pagination, filtering, and idempotency are essential patterns for building scalable REST APIs. Never return unbounded collections. Always design for safe retries. These patterns prevent performance issues and enable reliable client integrations.
Overview
This guide covers REST-specific patterns that address scalability and reliability:
- Pagination strategies for large datasets
- Filtering, sorting, and searching collections
- Idempotency keys for safe retries
- Async operations for long-running processes
- Request/response format conventions
Pagination
Pagination prevents performance issues and resource exhaustion by limiting the number of items returned in a single response. Without pagination, listing endpoints could return millions of records, overwhelming both server and client.
Never return unbounded collections. Always implement pagination for collection endpoints.
There are two primary pagination strategies, each with distinct trade-offs:
Page-Based (Offset) Pagination
Page-based pagination divides results into numbered pages. Clients specify which page they want and how many items per page. This approach is intuitive and enables random access to any page.
Request format:
GET /api/v1/users?page=0&size=20
GET /api/v1/users?page=2&size=50&sort=createdAt,desc
Response format:
{
"content": [
{ "userId": "123", "name": "John Doe", "email": "[email protected]" },
{ "userId": "124", "name": "Jane Smith", "email": "[email protected]" }
// ... 18 more items
],
"page": {
"number": 0,
"size": 20,
"totalElements": 1543,
"totalPages": 78
},
"links": {
"first": "/api/v1/users?page=0&size=20",
"last": "/api/v1/users?page=77&size=20",
"next": "/api/v1/users?page=1&size=20",
"prev": null
}
}
Implementation (Spring Boot example):
@GetMapping
public ResponseEntity<Page<UserDto>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String sort) {
// Limit max page size to prevent abuse
size = Math.min(size, 100);
Pageable pageable = PageRequest.of(page, size, Sort.by(parseSortParam(sort)));
Page<User> users = userService.getUsers(pageable);
// Response includes page number, total pages, total elements
return ResponseEntity.ok(users.map(UserDto::from));
}
Pros:
- Familiar UX: Users can jump to any page directly (page 1, 5, 100)
- Total count: Can provide total pages and items ("Showing 1-20 of 500 results")
- Simple implementation: Maps directly to SQL
OFFSETandLIMIT - Random access: Can navigate directly to last page or arbitrary page number
Cons:
- Performance degrades with large offsets:
OFFSET 10000 LIMIT 20must skip 10,000 rows - Inconsistent results: If items are added/removed between requests, users may see duplicates or miss items
- Not suitable for real-time data: Data changes invalidate page numbers
- Deep pagination expensive: Querying page 1000 of a million-record dataset is slow
Use page-based pagination when:
- Users need random access to pages (e.g., search results with page numbers)
- Total count is required for UI (e.g., "Showing 1-20 of 500 results")
- Dataset is relatively small (<10,000 records) or static
- Admin interfaces where page jumping is expected
Cursor-Based Pagination (Recommended)
Cursor-based pagination uses an opaque pointer (cursor) to the last retrieved item. The cursor identifies where to resume, eliminating offset-based skipping. This scales better for large datasets and provides consistent results even as data changes.
Request format:
GET /api/v1/users?limit=20
GET /api/v1/users?limit=20&cursor=eyJpZCI6MTIzLCJjcmVhdGVkQXQiOiIyMDI1...
Response format:
{
"data": [
{ "userId": "123", "name": "John Doe", "createdAt": "2025-01-15T10:30:00Z" },
{ "userId": "124", "name": "Jane Smith", "createdAt": "2025-01-15T10:29:30Z" }
// ... 18 more items
],
"pagination": {
"nextCursor": "eyJpZCI6MTQzLCJjcmVhdGVkQXQiOiIyMDI1...",
"hasMore": true,
"limit": 20
}
}
How cursor-based pagination works:
Implementation (Spring Boot example):
public record UserPage(
List<UserDto> users,
String nextCursor, // Opaque cursor for next page
boolean hasMore // Indicates if more pages exist
) {}
@GetMapping
public ResponseEntity<UserPage> getUsers(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") int limit) {
limit = Math.min(limit, 100);
// Parse cursor (e.g., Base64-encoded timestamp of last item)
Instant cursorTime = cursor != null
? decodeCursor(cursor)
: Instant.now();
// Fetch limit + 1 to determine if more pages exist
List<User> users = userService.getUsersBeforeCursor(cursorTime, limit + 1);
boolean hasMore = users.size() > limit;
List<User> result = hasMore ? users.subList(0, limit) : users;
// Next cursor is encoded timestamp of last item in current page
String nextCursor = hasMore
? encodeCursor(result.get(result.size() - 1).getCreatedAt())
: null;
UserPage page = new UserPage(
result.stream().map(UserDto::from).toList(),
nextCursor,
hasMore
);
return ResponseEntity.ok(page);
}
private Instant decodeCursor(String cursor) {
String decoded = new String(Base64.getDecoder().decode(cursor));
return Instant.parse(decoded);
}
private String encodeCursor(Instant timestamp) {
return Base64.getEncoder().encodeToString(timestamp.toString().getBytes());
}
Cursor encoding: The cursor is often a timestamp or ID of the last item. For complex sorting (multiple fields), encode multiple values in a Base64-encoded JSON object:
// Cursor with multiple sort fields
record CursorData(Instant createdAt, String id) {}
private String encodeCursor(Instant createdAt, String id) {
CursorData data = new CursorData(createdAt, id);
String json = objectMapper.writeValueAsString(data);
return Base64.getEncoder().encodeToString(json.getBytes());
}
Pros:
- Consistent results: Even if items are added/removed, users won't see duplicates or missing items
- Efficient: No offset skipping; queries use indexed range scans (
WHERE created_at < ?) - Scales to large datasets: Performance doesn't degrade with position in dataset
- Real-time friendly: Works well with frequently changing data
Cons:
- No random access: Can't jump to arbitrary pages
- No total count: Can't easily determine total items (though you can provide
hasMoreflag) - Requires stable sort order: Typically sorted by creation timestamp or immutable ID
- Opaque cursors: Clients can't construct cursors, must use values from responses
Use cursor-based pagination when:
- Dataset is large (>10,000 records) or grows frequently
- Infinite scroll UI pattern (e.g., social media feeds, mobile apps)
- Real-time data where consistency matters
- Performance is critical
- API clients are mobile apps or SPAs (single-page applications)
Pagination Best Practices
Limit maximum page size:
// Prevent abuse by capping page/limit size
size = Math.min(size, 100); // Never return more than 100 items
Provide sensible defaults:
@RequestParam(defaultValue = "20") int size
@RequestParam(defaultValue = "0") int page
Include navigation links (HATEOAS):
{
"links": {
"first": "/api/v1/users?page=0&size=20",
"self": "/api/v1/users?page=2&size=20",
"next": "/api/v1/users?page=3&size=20",
"prev": "/api/v1/users?page=1&size=20",
"last": "/api/v1/users?page=77&size=20"
}
}
Handle empty results:
{
"data": [],
"pagination": {
"nextCursor": null,
"hasMore": false
}
}
Filtering
Filtering limits collection results based on field values. Use query parameters for simple filters and POST with request body for complex filters.
Query Parameter Filtering
Single filter:
GET /api/v1/orders?status=PENDING
GET /api/v1/orders?userId=123
Multiple filters (AND logic):
GET /api/v1/orders?status=PENDING&userId=123&minAmount=100
Date range filtering:
GET /api/v1/orders?createdAfter=2025-01-01&createdBefore=2025-01-31
GET /api/v1/orders?createdAfter=2025-01-01T00:00:00Z&createdBefore=2025-01-31T23:59:59Z
Implementation (Spring Boot example):
@GetMapping
public ResponseEntity<List<OrderDto>> getOrders(
@RequestParam(required = false) OrderStatus status,
@RequestParam(required = false) String userId,
@RequestParam(required = false) BigDecimal minAmount,
@RequestParam(required = false) Instant createdAfter,
@RequestParam(required = false) Instant createdBefore) {
OrderFilter filter = new OrderFilter(status, userId, minAmount, createdAfter, createdBefore);
List<Order> orders = orderService.findOrders(filter);
return ResponseEntity.ok(orders.stream().map(OrderDto::from).toList());
}
POST Search for Complex Filters
For complex filters (OR logic, nested conditions, many fields), use POST with search criteria in request body:
POST /api/v1/orders/search
Content-Type: application/json
{
"filters": {
"status": ["PENDING", "PROCESSING"],
"amount": {
"min": 100,
"max": 1000
},
"createdAt": {
"after": "2025-01-01T00:00:00Z",
"before": "2025-01-31T23:59:59Z"
},
"userId": "123"
},
"page": 0,
"size": 20,
"sort": ["createdAt,desc", "amount,asc"]
}
Why POST for search? GET requests have URL length limits (~2000 characters). Complex filters with many criteria or long arrays exceed this limit. POST avoids URL length issues and provides better structure for complex queries.
Implementation:
public record SearchRequest(
OrderFilters filters,
Integer page,
Integer size,
List<String> sort
) {}
@PostMapping("/search")
public ResponseEntity<Page<OrderDto>> searchOrders(@Valid @RequestBody SearchRequest request) {
Pageable pageable = createPageable(request.page(), request.size(), request.sort());
Page<Order> orders = orderService.searchOrders(request.filters(), pageable);
return ResponseEntity.ok(orders.map(OrderDto::from));
}
Sorting
Use sort query parameter to specify sort order.
Single field sort:
GET /api/v1/users?sort=createdAt,desc
GET /api/v1/users?sort=lastName,asc
Multiple fields sort (priority order):
GET /api/v1/users?sort=lastName,asc&sort=firstName,asc
First sort by lastName ascending, then by firstName ascending for ties.
Format: field,direction where direction is asc (ascending) or desc (descending).
Default sort: If not specified, use a sensible default (typically creation time descending):
@RequestParam(defaultValue = "createdAt,desc") String sort
Implementation (Spring Boot example):
private Sort parseSortParam(List<String> sortParams) {
return Sort.by(sortParams.stream()
.map(this::parseSingleSort)
.toList());
}
private Sort.Order parseSingleSort(String sortParam) {
String[] parts = sortParam.split(",");
String field = parts[0];
Sort.Direction direction = parts.length > 1 && parts[1].equalsIgnoreCase("desc")
? Sort.Direction.DESC
: Sort.Direction.ASC;
return new Sort.Order(direction, field);
}
Sortable fields validation: Only allow sorting on indexed fields to prevent performance issues:
private static final Set<String> SORTABLE_FIELDS = Set.of(
"createdAt", "updatedAt", "lastName", "firstName", "email"
);
private void validateSortField(String field) {
if (!SORTABLE_FIELDS.contains(field)) {
throw new InvalidSortFieldException(
"Field '" + field + "' is not sortable. Allowed fields: " + SORTABLE_FIELDS
);
}
}
Searching
Full-text search across multiple fields using a single search query.
Simple search (single parameter):
GET /api/v1/users?q=john
GET /api/v1/users?search=john
Searches across default fields (e.g., name, email).
Search with field specification:
GET /api/v1/users?q=john&searchFields=name,email,address
Implementation (Spring Boot with JPA):
@GetMapping
public ResponseEntity<List<UserDto>> searchUsers(
@RequestParam(required = false) String q,
@RequestParam(required = false) List<String> searchFields) {
if (q == null || q.isBlank()) {
return getUsers(); // Return all with pagination
}
List<String> fields = searchFields != null ? searchFields : List.of("name", "email");
List<User> users = userService.search(q, fields);
return ResponseEntity.ok(users.stream().map(UserDto::from).toList());
}
For advanced search, use dedicated search endpoints with POST:
POST /api/v1/users/search
Content-Type: application/json
{
"query": "john",
"fields": ["name", "email", "address"],
"filters": {
"status": "ACTIVE",
"createdAfter": "2025-01-01"
},
"page": 0,
"size": 20
}
Idempotency Keys
POST is not naturally idempotent. If a client sends a POST request but doesn't receive a response (network timeout), retrying creates duplicate resources. Idempotency keys make POST safe to retry.
How idempotency keys work:
Request with idempotency key:
POST /api/v1/payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
Content-Type: application/json
{
"from": "123",
"to": "456",
"amount": 100.00,
"currency": "USD"
}
201 Created
Location: /api/v1/payments/PAY-789
Content-Type: application/json
{
"paymentId": "PAY-789",
"status": "PROCESSING",
"createdAt": "2025-01-15T10:30:00Z"
}
Retry with same idempotency key:
POST /api/v1/payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
Content-Type: application/json
{
"from": "123",
"to": "456",
"amount": 100.00,
"currency": "USD"
}
200 OK
Content-Type: application/json
{
"paymentId": "PAY-789",
"status": "COMPLETED",
"createdAt": "2025-01-15T10:30:00Z"
}
Returns the already-created payment. No duplicate created.
Implementation (Spring Boot with Redis):
@Service
public class IdempotencyService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final long TTL_HOURS = 24;
public Optional<String> getResult(String idempotencyKey) {
return Optional.ofNullable(
redisTemplate.opsForValue().get(idempotencyKey)
);
}
public void storeResult(String idempotencyKey, String result) {
redisTemplate.opsForValue().set(
idempotencyKey,
result,
Duration.ofHours(TTL_HOURS)
);
}
}
@PostMapping
public ResponseEntity<PaymentDto> createPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreatePaymentRequest request) {
// Check if we've seen this idempotency key before
Optional<String> cachedResult = idempotencyService.getResult(idempotencyKey);
if (cachedResult.isPresent()) {
// Return existing result, don't create duplicate
Payment existing = paymentService.getPayment(cachedResult.get());
return ResponseEntity.ok(PaymentDto.from(existing));
}
// First time seeing this key, process request
Payment payment = paymentService.createPayment(request);
// Cache the result for future retries
idempotencyService.storeResult(idempotencyKey, payment.getId());
return ResponseEntity.created(
URI.create("/api/v1/payments/" + payment.getId())
).body(PaymentDto.from(payment));
}
Idempotency key requirements:
- Must be unique per request (typically UUID v4)
- Generated by client, not server
- Stored with TTL (typically 24 hours)
- Same key + different request body = error (conflict)
Validation: If same idempotency key with different request body, return 409 Conflict:
if (cachedResult.isPresent()) {
Payment existing = paymentService.getPayment(cachedResult.get());
// Verify request body matches
if (!requestMatches(request, existing)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
"Idempotency key conflict",
"Same idempotency key used with different request body"
));
}
return ResponseEntity.ok(PaymentDto.from(existing));
}
Asynchronous Operations
Some operations take too long for synchronous HTTP requests (>30 seconds). For long-running operations, return immediately with a job tracking URL.
Pattern: Return 202 Accepted immediately:
POST /api/v1/reports/generate
Content-Type: application/json
{
"reportType": "ANNUAL_SUMMARY",
"year": 2024,
"format": "PDF"
}
202 Accepted
Location: /api/v1/reports/jobs/456
Content-Type: application/json
{
"jobId": "456",
"status": "PROCESSING",
"createdAt": "2025-01-15T10:30:00Z",
"links": {
"self": "/api/v1/reports/jobs/456",
"cancel": "/api/v1/reports/jobs/456/cancel"
}
}
Client polls for completion:
GET /api/v1/reports/jobs/456
200 OK
Content-Type: application/json
{
"jobId": "456",
"status": "COMPLETED",
"createdAt": "2025-01-15T10:30:00Z",
"completedAt": "2025-01-15T10:35:00Z",
"result": {
"reportId": "789",
"downloadUrl": "/api/v1/reports/789/download"
}
}
Status values:
PENDING: Job queued, not yet startedPROCESSING: Job in progressCOMPLETED: Job finished successfullyFAILED: Job failed (include error details)CANCELLED: Job cancelled by user
Implementation (Spring Boot example):
public record JobStatus(
String jobId,
String status,
Instant createdAt,
Instant completedAt,
Object result,
String error
) {}
@PostMapping("/generate")
public ResponseEntity<JobStatus> generateReport(@Valid @RequestBody ReportRequest request) {
String jobId = reportService.scheduleReport(request);
URI statusLocation = ServletUriComponentsBuilder
.fromCurrentContextPath()
.path("/api/v1/reports/jobs/{jobId}")
.buildAndExpand(jobId)
.toUri();
JobStatus status = new JobStatus(
jobId,
"PROCESSING",
Instant.now(),
null,
null,
null
);
return ResponseEntity.accepted()
.location(statusLocation)
.body(status);
}
@GetMapping("/jobs/{jobId}")
public ResponseEntity<JobStatus> getJobStatus(@PathVariable String jobId) {
JobStatus status = reportService.getJobStatus(jobId);
return ResponseEntity.ok(status);
}
Polling recommendations:
- Start with 1-second intervals
- Use exponential backoff (1s, 2s, 4s, 8s, max 30s)
- Include
Retry-Afterheader in 202 responses to suggest poll interval
Alternative: WebSockets or Server-Sent Events for real-time updates instead of polling. See Real-Time Communication for patterns.
Request and Response Format Conventions
JSON Naming Conventions
Use camelCase for JSON field names (standard for JavaScript):
{
"userId": "123",
"firstName": "John",
"lastName": "Doe",
"createdAt": "2025-01-15T10:30:00Z",
"accountBalance": 1000.50
}
Be consistent. Don't mix camelCase, snake_case, and PascalCase in the same API.
Date and Time Format
Always use ISO 8601 format in UTC (with Z suffix):
{
"createdAt": "2025-01-15T10:30:00Z",
"updatedAt": "2025-01-15T14:20:15.123Z"
}
Zsuffix indicates UTC timezone- Include milliseconds if precision matters
- Always use UTC, never local time zones
Null vs. Absent Fields
Absent fields (not included) vs. null fields (explicitly null) have different meanings:
// Field absent: Not applicable or unknown
{
"userId": "123",
"name": "John Doe"
// email field not included
}
// Field null: Explicitly set to no value
{
"userId": "123",
"name": "John Doe",
"email": null // User explicitly has no email
}
Recommendation: Omit fields that don't have values rather than setting them to null, unless the distinction matters semantically. This reduces response size and simplifies client deserialization.
Further Reading
Internal Documentation
- REST Fundamentals - REST principles, HTTP methods, status codes
- REST Versioning - API versioning strategies
- API Contracts - Request/response models and validation
- API Patterns - General API patterns (error handling, caching, rate limiting)
- Caching - Caching strategies and implementation
- Spring Boot API Design - Spring Boot implementation
External Resources
- HTTP/1.1 RFC 7231
- API Pagination Best Practices
- Stripe API Pagination - Real-world cursor pagination example
- ISO 8601 Date Format
Summary
Key Takeaways:
- Always paginate - Never return unbounded collections; use cursor-based for large/dynamic data
- Filter with query params - Simple filters as query params, complex filters via POST search
- Sort explicitly - Provide sort parameters, default to sensible order (createdAt desc)
- Full-text search - Use
qorsearchparameter for keyword search - Idempotency keys - Make POST safe to retry for critical operations (payments, orders)
- Async for long operations - Return 202 Accepted with job tracking URL
- Consistent formatting - camelCase fields, ISO 8601 dates, omit null fields
- Poll with backoff - Exponential backoff for job status polling
Next Steps: Review REST Versioning for API evolution strategies, then API Contracts for request/response modeling.