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.
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.
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:
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.
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.
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:
With this configuration, SDK users can iterate pages seamlessly:
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.
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.