How to Implement Pagination in REST APIs (Step by Step Guide)

Mark Ponomarev

Mark Ponomarev

19 May 2025

How to Implement Pagination in REST APIs (Step by Step Guide)

When building REST APIs that return a list of resources, it's crucial to consider how to handle large datasets. Returning thousands or even millions of records in a single API response is impractical and can lead to significant performance issues, high memory consumption for both the server and the client, and a poor user experience. Pagination is the standard solution to this problem. It involves breaking down a large dataset into smaller, manageable chunks called "pages," which are then served sequentially. This tutorial will guide you through the technical steps of implementing various pagination strategies in your REST APIs.

💡
Want a great API Testing tool that generates beautiful API Documentation?

Want an integrated, All-in-One platform for your Developer Team to work together with maximum productivity?

Apidog delivers all your demans, and replaces Postman at a much more affordable price!
button

Why is Pagination Essential?

Before diving into implementation details, let's briefly touch upon why pagination is a non-negotiable feature for APIs dealing with collections of resources:

  1. Performance: Requesting and transferring large amounts of data can be slow. Pagination reduces the payload size of each request, leading to faster response times and reduced server load.
  2. Resource Consumption: Smaller responses consume less memory on the server generating them and on the client parsing them. This is especially critical for mobile clients or environments with limited resources.
  3. Rate Limiting and Quotas: Many APIs enforce rate limits. Pagination helps clients stay within these limits by fetching data in smaller pieces over time, rather than trying to get everything at once.
  4. User Experience: For UIs consuming the API, presenting data in pages is much more user-friendly than overwhelming users with an enormous list or a very long scroll.
  5. Database Efficiency: Fetching a subset of data is generally less taxing on the database compared to retrieving an entire table, especially if proper indexing is in place.

Common Pagination Strategies

There are several common strategies for implementing pagination, each with its own set of trade-offs. We'll explore the most popular ones: offset/limit (often referred to as page-based) and cursor-based (also known as keyset or seek pagination).

1. Offset/Limit (or Page-Based) Pagination

This is arguably the most straightforward and widely adopted pagination method. It works by allowing the client to specify two main parameters:

Alternatively, clients might specify:

The offset can be calculated from page and pageSize using the formula: offset = (page - 1) * pageSize.

Technical Implementation Steps:

Let's assume we have an API endpoint /items that returns a list of items.

a. API Request Parameters:
The client would make a request like:
GET /items?offset=20&limit=10 (fetch 10 items, skipping the first 20)
or
GET /items?page=3&pageSize=10 (fetch the 3rd page, with 10 items per page, which is equivalent to offset=20, limit=10).

It's good practice to set default values for these parameters (e.g., limit=20, offset=0 or page=1, pageSize=20) if the client doesn't provide them. Also, enforce a maximum limit or pageSize to prevent clients from requesting an excessively large number of records, which could strain the server.

b. Backend Logic (Conceptual):
When the server receives this request, it needs to translate these parameters into a database query.

// Example in Java with Spring Boot
@GetMapping("/items")
public ResponseEntity<PaginatedResponse<Item>> getItems(
    @RequestParam(defaultValue = "0") int offset,
    @RequestParam(defaultValue = "20") int limit
) {
    // Validate limit to prevent abuse
    if (limit > 100) {
        limit = 100; // Enforce a max limit
    }

    List<Item> items = itemRepository.findItemsWithOffsetLimit(offset, limit);
    long totalItems = itemRepository.countTotalItems(); // For metadata

    // Construct and return paginated response
    // ...
}

c. Database Query (SQL Example):
Most relational databases support offset and limit clauses directly.

For PostgreSQL or MySQL:

SELECT *
FROM items
ORDER BY created_at DESC -- Consistent ordering is crucial for stable pagination
LIMIT 10 -- This is the 'limit' parameter
OFFSET 20; -- This is the 'offset' parameter

For SQL Server (older versions might use ROW_NUMBER()):

SELECT *
FROM items
ORDER BY created_at DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;

For Oracle:

SELECT *
FROM (
    SELECT i.*, ROWNUM rnum
    FROM (
        SELECT *
        FROM items
        ORDER BY created_at DESC
    ) i
    WHERE ROWNUM <= 20 + 10 -- offset + limit
)
WHERE rnum > 20; -- offset

Important Note on Ordering: For offset/limit pagination to be reliable, the underlying dataset must be sorted by a consistent and unique (or near-unique) key, or a combination of keys. If the order of items can change between requests (e.g., new items being inserted or items being updated in a way that affects their sort order), users might see duplicate items or miss items when navigating pages. A common choice is to sort by creation timestamp or a primary ID.

d. API Response Structure:
A good paginated response should not only include the data for the current page but also metadata to help the client navigate.

{
  "data": [
    // array of items for the current page
    { "id": "item_21", "name": "Item 21", ... },
    { "id": "item_22", "name": "Item 22", ... },
    // ... up to 'limit' items
    { "id": "item_30", "name": "Item 30", ... }
  ],
  "pagination": {
    "offset": 20,
    "limit": 10,
    "totalItems": 5000, // Total number of items available
    "totalPages": 500, // Calculated as ceil(totalItems / limit)
    "currentPage": 3 // Calculated as (offset / limit) + 1
  },
  "links": { // HATEOAS links for navigation
    "self": "/items?offset=20&limit=10",
    "first": "/items?offset=0&limit=10",
    "prev": "/items?offset=10&limit=10", // Null if on the first page
    "next": "/items?offset=30&limit=10", // Null if on the last page
    "last": "/items?offset=4990&limit=10"
  }
}

Providing HATEOAS (Hypermedia as the Engine of Application State) links (self, first, prev, next, last) is a REST best practice. It allows clients to navigate through the pages without having to construct the URLs themselves.

Pros of Offset/Limit Pagination:

Cons of Offset/Limit Pagination:

2. Cursor-Based (Keyset/Seek) Pagination

Cursor-based pagination addresses some of the shortcomings of offset/limit, particularly performance with large datasets and data consistency issues. Instead of relying on an absolute offset, it uses a "cursor" that points to a specific item in the dataset. The client then requests items "after" or "before" this cursor.

The cursor is typically an opaque string that encodes the value(s) of the sort key(s) of the last item retrieved on the previous page.

Technical Implementation Steps:

a. API Request Parameters:
The client would make a request like:
GET /items?limit=10 (for the first page)
And for subsequent pages:
GET /items?limit=10&after_cursor=opaquestringrepresentinglastitemid
Or, to paginate backward (less common but possible):
GET /items?limit=10&before_cursor=opaquestringrepresentingfirstitemid

The limit parameter still defines the page size.

b. What is a Cursor?
A cursor should be:

c. Backend Logic (Conceptual):

// Example in Java with Spring Boot
@GetMapping("/items")
public ResponseEntity<CursorPaginatedResponse<Item>> getItems(
    @RequestParam(defaultValue = "20") int limit,
    @RequestParam(required = false) String afterCursor
) {
    // Validate limit
    if (limit > 100) {
        limit = 100;
    }

    // Decode cursor to get the last seen item's properties
    // e.g., LastSeenItemDetails lastSeen = decodeCursor(afterCursor);
    // If afterCursor is null, it's the first page.

    List<Item> items;
    if (afterCursor != null) {
        DecodedCursor decoded = decodeCursor(afterCursor); // e.g., { lastId: "some_uuid", lastCreatedAt: "timestamp" }
        items = itemRepository.findItemsAfter(decoded.getLastCreatedAt(), decoded.getLastId(), limit);
    } else {
        items = itemRepository.findFirstPage(limit);
    }

    String nextCursor = null;
    if (!items.isEmpty() && items.size() == limit) {
        // Assuming items are sorted, the last item in the list is used to generate the next cursor
        Item lastItemOnPage = items.get(items.size() - 1);
        nextCursor = encodeCursor(lastItemOnPage.getCreatedAt(), lastItemOnPage.getId());
    }

    // Construct and return cursor paginated response
    // ...
}

// Helper methods for encoding/decoding cursors
// private DecodedCursor decodeCursor(String cursor) { ... }
// private String encodeCursor(Timestamp createdAt, String id) { ... }

d. Database Query (SQL Example):
The key is to use a WHERE clause that filters records based on the sort key(s) from the cursor. The ORDER BY clause must align with the cursor's composition.

Assuming sorting by created_at (descending) and then by id (descending) as a tie-breaker for stable ordering if created_at is not unique:

For the first page:

SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;

For subsequent pages, if the cursor decoded to last_created_at_from_cursor and last_id_from_cursor:

SELECT *
FROM items
WHERE (created_at, id) < (CAST('last_created_at_from_cursor' AS TIMESTAMP), CAST('last_id_from_cursor' AS UUID)) -- Or appropriate types
-- For ascending order, it would be >
-- The tuple comparison (created_at, id) < (val1, val2) is a concise way to write:
-- WHERE created_at < 'last_created_at_from_cursor'
--    OR (created_at = 'last_created_at_from_cursor' AND id < 'last_id_from_cursor')
ORDER BY created_at DESC, id DESC
LIMIT 10;

This type of query is very efficient, especially if there's an index on (created_at, id). The database can directly "seek" to the starting point without scanning irrelevant rows.

e. API Response Structure:

{
  "data": [
    // array of items for the current page
    { "id": "item_N", "createdAt": "2023-10-27T10:05:00Z", ... },
    // ... up to 'limit' items
    { "id": "item_M", "createdAt": "2023-10-27T10:00:00Z", ... }
  ],
  "pagination": {
    "limit": 10,
    "hasNextPage": true, // boolean indicating if there's more data
    "nextCursor": "base64encodedcursorstringforitem_M" // opaque string
    // Potentially a "prevCursor" if bi-directional cursors are supported
  },
  "links": {
    "self": "/items?limit=10&after_cursor=current_request_cursor_if_any",
    "next": "/items?limit=10&after_cursor=base64encodedcursorstringforitem_M" // Null if no next page
  }
}

Notice that cursor-based pagination typically doesn't provide totalPages or totalItems because calculating these would require a full table scan, negating some of the performance benefits. If these are strictly needed, a separate endpoint or an estimate might be provided.

Pros of Cursor-Based Pagination:

Cons of Cursor-Based Pagination:

Choosing the Right Strategy

The choice between offset/limit and cursor-based pagination depends on your specific requirements:

In some systems, a hybrid approach is even used, or different strategies are offered for different use cases or endpoints.

Best Practices for Implementing Pagination

Regardless of the chosen strategy, adhere to these best practices:

  1. Consistent Parameter Naming: Use clear and consistent names for your pagination parameters (e.g., limit, offset, page, pageSize, after_cursor, before_cursor). Stick to one convention (e.g., camelCase or snake_case) throughout your API.
  2. Provide Navigation Links (HATEOAS): As shown in the response examples, include links for self, next, prev, first, and last (where applicable). This makes the API more discoverable and decouples the client from URL construction logic.
  3. Default Values and Max Limits:
  1. Clear API Documentation: Document your pagination strategy thoroughly:
  1. Consistent Sorting: Ensure that the underlying data is sorted consistently for every paginated request. For offset/limit, this is vital to avoid data skew. For cursor-based, the sort order dictates how cursors are constructed and interpreted. Use a unique tie-breaker column (like a primary ID) if the primary sort column can have duplicate values.
  2. Handle Edge Cases:
  1. Total Count Considerations:
  1. Error Handling: Return appropriate HTTP status codes for errors (e.g., 400 for bad input, 500 for server errors during data fetching).
  2. Security: While not directly a pagination mechanism, ensure that the data being paginated respects authorization rules. A user should only be able to paginate through data they are permitted to see.
  3. Caching: Paginated responses can often be cached. For offset-based pagination, GET /items?page=2&pageSize=10 is highly cacheable. For cursor-based, GET /items?limit=10&after_cursor=XYZ is also cacheable. Ensure your caching strategy works well with how pagination links are generated and consumed. Invalidation strategies need to be considered if the underlying data changes frequently.

Advanced Topics (Brief Mentions)

Conclusion

Implementing pagination correctly is fundamental to building scalable and user-friendly REST APIs. While offset/limit pagination is simpler to start with, cursor-based pagination offers superior performance and consistency for large, dynamic datasets. By understanding the technical details of each strategy, choosing the one that best fits your application's needs, and following best practices for implementation and API design, you can ensure that your API efficiently delivers data to your clients, no matter the scale. Remember to always prioritize clear documentation and robust error handling to provide a smooth experience for API consumers.


Explore more

How to Get Started with PostHog MCP Server

How to Get Started with PostHog MCP Server

Discover how to install PostHog MCP Server on Cline in VS Code/Cursor, automate analytics with natural language, and see why PostHog outshines Google Analytics!

30 June 2025

A Developer's Guide to the OpenAI Deep Research API

A Developer's Guide to the OpenAI Deep Research API

In the age of information overload, the ability to conduct fast, accurate, and comprehensive research is a superpower. Developers, analysts, and strategists spend countless hours sifting through documents, verifying sources, and synthesizing findings. What if you could automate this entire workflow? OpenAI's Deep Research API is a significant step in that direction, offering a powerful tool to transform high-level questions into structured, citation-rich reports. The Deep Research API isn't jus

27 June 2025

How to Get Free Gemini 2.5 Pro Access + 1000 Daily Requests (with Google Gemini CLI)

How to Get Free Gemini 2.5 Pro Access + 1000 Daily Requests (with Google Gemini CLI)

Google's free Gemini CLI, the open-source AI agent, rivals its competitors with free access to 1000 requests/day and Gemini 2.5 pro. Explore this complete Gemini CLI setup guide with MCP server integration.

27 June 2025

Practice API Design-first in Apidog

Discover an easier way to build and use APIs