How to Implement REST API Pagination: Offset, Cursor, Keyset

How to implement pagination in REST API with offset, cursor and keyset methods for efficient data retrieval and scalable performance.

Jump to section

Jump to section

Jump to section

Pagination is one of those API design decisions that seems straightforward until you're debugging why users are seeing duplicate items or your database is grinding to a halt on large offsets. At Stainless, we see this pattern repeatedly when working with API teams: they start with simple offset pagination, hit performance walls, then scramble to retrofit cursor-based approaches without breaking existing integrations.

This guide covers the three main pagination patterns (offset, cursor, and keyset), their trade-offs, and implementation details that matter for production APIs. You'll learn when to choose each approach, how to handle edge cases that break user experiences, and how proper pagination design in your OpenAPI spec translates to better SDK ergonomics across all generated client libraries.

Why pagination matters in REST APIs

Implementing pagination in a REST API means breaking a large list of resources into smaller, manageable chunks called pages. This is crucial for protecting your backend from expensive queries and improving the client-side experience by reducing payload size. Without it, an endpoint like GET /items could try to return millions of records at once, overloading your database and sending a massive payload that is slow for clients to download and parse.

The most common patterns are offset, cursor, and keyset pagination, each with different performance and consistency trade-offs. Pagination solves this by letting clients request data one piece at a time.

Reduce payload size

Smaller, paginated responses mean faster load times for your users. A client can render the first page of results quickly while fetching subsequent pages in the background. This dramatically improves the perceived performance of your application.

Protect backend resources

Pagination acts as a safeguard for your database and application servers. By enforcing limits on how much data can be fetched in a single request, you prevent expensive, long-running queries that can degrade performance for all users. It’s a fundamental part of building a scalable and resilient API.

Improve developer experience

A well-designed pagination strategy makes your API predictable and easier to work with. When you define this strategy in an OpenAPI specification, tools can generate client SDKs with built-in helpers. Creating OpenAPI specs with proper pagination definitions ensures consistency across all generated code. For example, instead of forcing developers to manually track page tokens, an SDK can provide an auto-iterator that makes looping through a list of items as simple as a native for loop.

Choose the right pagination pattern

The three main pagination strategies are offset, cursor, and keyset. Choosing the right one depends on your data's characteristics and the user experience you want to provide. There is no single best answer, but there are clear trade-offs to consider.

A good rule of thumb is to match the pattern to your dataset's nature. Is it a small, mostly static list, or a constantly changing real-time feed? Once you decide on a pattern, you can declare it once in your configuration file, and an SDK generator can implement the correct helpers in every SDK you produce.

Pattern

Best For

Pros

Cons

Offset

Small, stable datasets where users might jump to a specific page number.

Simple to implement and understand. Allows random access to pages.

Inconsistent results with frequently updated data. Poor performance on large datasets.

Cursor

Large, dynamic datasets like feeds or timelines where data is added frequently.

Consistent ordering, even with new data. Good performance.

Does not allow jumping to a specific page. Cursors can be complex to generate.

Keyset

Very large, ordered datasets where performance is critical.

Highest performance. Very stable.

Only works on indexed, unique columns. More complex to implement multi-column sorting.

Implement offset pagination

Offset pagination, sometimes called page-based pagination, is the most traditional approach. The client specifies a limit (how many items per page) and an offset (how many items to skip). It’s intuitive because it maps directly to the concept of page numbers.

A typical request looks like this: GET /items?limit=20&offset=40. This would fetch items 41 through 60, effectively "page 3" if the page size is 20.

Set limit and offset parameters

Your API should accept limit and offset as query parameters. It's best practice to set a reasonable default and a maximum for the limit to prevent clients from requesting too much data at once.

-- SQL for offset pagination
SELECT * FROM items
ORDER BY created_at DESC
LIMIT 20 OFFSET 40

Return total and next links

The response should include the list of items and metadata to help the client navigate. This often includes the total number of items available, allowing the client to calculate the total number of pages. You can also provide direct links to the next and previous pages in the response body or via the Link HTTP header.

{
  "total": 5280,
  "items": [
    { "id": "item_41", "name": "..." }
    // ... 19 more items
  ],
  "next_url": "/items?limit=20&offset=60",
  "prev_url": "/items?limit=20&offset=20"
}

Handle large offsets

The main drawback of offset pagination is performance degradation on large datasets. An OFFSET clause still requires the database to scan and count all the rows up to the offset before returning the result. For an API with millions of records, fetching page 50,000 can become extremely slow.

  • The Page Drift Problem: If a new item is added to the beginning of the list while a user is paginating, every subsequent page will be shifted. An item the user saw on page 2 might reappear on page 3, creating a confusing and broken experience.

Stainless config for offset pagination

Here’s how you could declare offset-based pagination in your stainless.yml to generate SDK helpers automatically:

# In your stainless.yml
pagination:
  - name: offsetPagination
    type: offset
    request:
      limit:
        type: integer
        default: 20
      offset:
        type: integer
        default: 0
    response:
      items:
        type: array
        x-stainless-pagination-property:
          purpose: items
      total:
        type: integer
        x-stainless-pagination-property:
          purpose: total_count
      next_url:
        type: string
        nullable: true
        x-stainless-pagination-property:
          purpose: next_page_url
      prev_url:
        type: string
        nullable: true
        x-stainless-pagination-property:
          purpose: previous_page_url

resources:
  items:
    methods:
      list:
        paginated: offsetPagination
        endpoint

Implement cursor pagination

Cursor-based pagination solves the performance and consistency problems of the offset method. Instead of a page number, the API provides an opaque cursor string that points to a specific item in the dataset. To get the next page, the client sends back the cursor from the last item it received.

A request using a cursor looks like this: GET /items?limit=20&after_cursor=aBcDeFg123.

Generate and sign cursors

A cursor is typically a base64-encoded value representing the sort key and unique ID of the last item on the previous page. For example, it could encode {"created_at": "2024-01-01T12:00:00Z", "id": 123}. This allows the database to efficiently jump to that exact point in the sorted list.

-- SQL for cursor pagination
SELECT * FROM items
WHERE created_at <= '2024-01-01T12:00:00Z'
ORDER BY created_at DESC
LIMIT 20

Paginate forward and backward

A robust implementation provides cursors for both forward and backward navigation. The response should contain the list of items and the cursors needed to get the next or previous page.

{
  "items": [
    { "id": "item_21", "name": "..." }
    // ... 19 more items
  ],
  "cursors": {
    "after": "aBcDeFg123",
    "before": "xYzAbCd456"
  }
}

Expire stale cursors

Because a cursor points to a specific record, it can become invalid if that record is deleted. Your API should handle this gracefully, perhaps by returning an error or starting from the beginning of the list. You can also give cursors a time-to-live (TTL) to prevent them from being used indefinitely.

Stainless config for cursor pagination

You can also map the cursor-based approach in your stainless.yml for automatic SDK generation:

pagination:
  - name: cursorPagination
    type: cursor
    request:
      limit:
        type: integer
        default: 20
      after_cursor:
        type: string
        x-stainless-pagination-property:
          purpose: next_cursor_param
      before_cursor:
        type: string
        x-stainless-pagination-property:
          purpose: previous_cursor_param
    response:
      items:
        type: array
        x-stainless-pagination-property:
          purpose: items
      cursors:
        type: object
        properties:
          after:
            type: string
            x-stainless-pagination-property:
              purpose: next_cursor_field
          before:
            type: string
            x-stainless-pagination-property:
              purpose: previous_cursor_field

resources:
  items:
    methods:
      list:
        paginated: cursorPagination
        endpoint

With this configuration, SDK users can iterate pages seamlessly:

for await (const item of client.items.list({ limit: 20 })) {
  // process each item
}

Implement keyset pagination

Keyset pagination, also known as the "seek method," is a more performant and specific variant of cursor pagination. Instead of an opaque cursor, it uses the actual value of the last-seen record's primary key or another unique, sorted column. This is the most efficient method for very large tables.

Choose stable sort key

This method requires a unique, immutable, and indexed column to sort by, such as an auto-incrementing id or a created_at timestamp. The query uses this key to find where the next page should start.

Filter by key range

The server-side implementation is a simple WHERE clause that is highly efficient, as it allows the database to use an index to seek directly to the starting point.

-- SQL for keyset pagination
SELECT * FROM items
WHERE id > 40 -- ID of the last item from the previous page
ORDER BY id ASC
LIMIT 20

Combine with secondary sort

If the primary sort key is not unique (like a timestamp), you must add a secondary, unique sort key (like the id) to act as a tie-breaker. This ensures the ordering is always deterministic and no records are skipped.

Test and harden pagination

No matter which pattern you choose, thorough testing is essential to ensure a reliable experience. A buggy pagination implementation can be more frustrating for developers than having no pagination at all.

Run boundary tests

Your test suite should cover all edge cases.

  • What happens on the first page?

  • What happens when a page is empty or has fewer items than the limit?

  • What happens when a client requests a page beyond the last one?

  • How does the API handle items being deleted while a user is paginating?

Expose consistent response schema

Ensure that every paginated endpoint returns a response with a consistent shape. The client should always know where to find the list of items and the pagination metadata, like has_more flags or cursor tokens. Using a generator to create your SDKs from an OpenAPI spec enforces this consistency automatically, because SDK shipping is an integral part of delivering a complete API experience.

Document limits and examples

Your API documentation must clearly explain your pagination strategy. SDK snippet integration helps provide copy-pasteable examples that demonstrate pagination in multiple languages. Include the names of the query parameters, default and maximum limits, and provide copy-pasteable example requests and responses.

Frequently asked questions about REST API pagination

What are two main reasons to paginate?

The two primary reasons are performance optimization for your backend and providing a faster, more predictable user experience for clients consuming your API.

How do I add pagination to an existing API without breaking clients?

You can introduce pagination by adding new, optional query parameters like limit and cursor while keeping the old behavior as the default for backward compatibility.

Which pagination scheme works best for real-time feeds?

Cursor-based pagination is ideal for real-time feeds because it maintains a stable view of the data, preventing items from being skipped or repeated as new data arrives.

How do SDKs iterate over pages?

A well-built SDK provides an auto-paginating iterator, allowing a developer to use a simple for await loop that transparently handles fetching subsequent pages under the hood. Advanced use cases might require custom code persistence to extend the default pagination behavior.

Can I mix multiple pagination schemes in one API?

Yes, it's possible to support different schemes for different endpoints, but you should clearly define each one in your configuration and document which endpoints use which scheme.

Ready to ship SDKs with automatic pagination built-in? Get started for free.