What is API Pagination? Cursor vs Offset Strategies
API pagination splits big result sets into pages. Compare offset, page, cursor and keyset pagination, with SQL examples, Link headers and pitfalls.
What is API pagination?
API pagination returns a large result set in smaller chunks, called pages, over several requests. Instead of one response holding a million records, the client asks for 50 or 100 at a time and requests the next chunk when it needs it. This protects the database, memory and network, and keeps response times predictable.
The strategy you choose affects speed, correctness and what the client can do. Offset pagination is the simplest, cursor and keyset pagination hold up better on large or changing data. A poor choice is a common reason an endpoint gets slower as data grows.
The main pagination strategies
| Strategy | Request shape | Best for |
|---|---|---|
| Offset and limit | ?offset=200&limit=50 | Small datasets, jump-to-page screens |
| Page number | ?page=5&per_page=50 | Same as offset with a friendlier URL |
| Cursor | ?cursor=abc123&limit=50 | Feeds, infinite scroll, changing data |
| Keyset (seek) | ?after_id=12345&limit=50 | Large datasets with a stable sort |
Offset and limit
The client says how many rows to skip and how many to return.
SELECT * FROM users
ORDER BY created_at, id
LIMIT 50 OFFSET 200;Pros: easy to build, supports jumping to page 7, makes a total count simple. Cons: the database still has to walk past every skipped row, so deep pages get slow. If rows are inserted or deleted between requests, items can repeat or be missed.
Cursor-based pagination
The server returns an opaque token that marks where the last page ended, and the client sends it back to get the next page.
GET /api/users?cursor=eyJpZCI6MTIzNDV9&limit=50
{
"data": [ ... ],
"next_cursor": "eyJpZCI6MTIzOTV9",
"has_more": true
}A cursor is often a base64-encoded sort key such as {"id": 12345}. Pros: stable when data changes, fast when the sort key is indexed, a natural fit for infinite scroll. Cons: no jumping to an arbitrary page, and a total count needs a separate query.
Keyset (seek) pagination
Keyset pagination is what a cursor usually does underneath: the query filters on the last value seen instead of skipping rows.
SELECT * FROM users
WHERE id > 12345
ORDER BY id
LIMIT 50;With a composite sort, compare the columns together. Databases that support row comparison, such as PostgreSQL, accept this:
SELECT * FROM users
WHERE (created_at, id) > ('2026-01-15', 12345)
ORDER BY created_at, id
LIMIT 50;With an index on the sort columns, the database jumps straight to the starting point, so page 1,000 costs about the same as page 1.
Why deep offsets get slow
With a page size of 50, the database has to step over the skipped rows before returning the 50 you asked for. The table shows that cost for an offset query versus a keyset query. It is simple arithmetic, not a benchmark, and real timings depend on indexes and engine.
| Page | Rows to step past plus return (offset) | Rows to step past plus return (keyset) |
|---|---|---|
| 1 | 50 | 50 |
| 10 | 500 | 50 |
| 100 | 5,000 | 50 |
| 1,000 | 50,000 | 50 |
| 10,000 | 500,000 | 50 |
How clients find the next page
There are two common patterns. The first puts navigation in the Link header, defined by RFC 8288 (Web Linking, which replaced RFC 5988):
Link: <https://api.example.com/users?page=3&per_page=50>; rel="next",
<https://api.example.com/users?page=1&per_page=50>; rel="prev"GitHub's REST API works this way, with next, prev, first and last relations, and caps per_page at 100 for most endpoints. Clients should follow these URLs rather than build their own. The second pattern puts metadata in the response payload: a next_cursor and a has_more flag, or a pagination object with page, per-page and totals. GraphQL APIs usually follow the Relay cursor connection pattern, with edges, pageInfo.hasNextPage and endCursor, and the GraphQL docs recommend cursor-based pagination.
Best practices
- Set a default and a maximum page size. For example 25 to 50 by default and a hard cap you document. Reject or clamp anything above it.
- Use a stable, unique sort. Add the id as a tiebreaker, or rows with equal values will repeat or vanish between pages.
- Index the sort columns. Otherwise every page is a full sort.
- Prefer keyset or cursor for big or growing tables. Keep offset for small admin lists.
- Be careful with total counts.
COUNT(*)on a large table is slow. Offer an estimate, or none. - Document the scheme. Say in your OpenAPI spec how to request the next page and when the list ends.
- Cache the first page. It is the most requested. See API caching.
Common pagination mistakes
- Deep offsets.
offset=100000on a big table is the classic slow query. - Non-unique ordering. Sorting by a column with ties duplicates or skips rows.
- No maximum limit. A client sends
limit=1000000and you have denied service to yourself. - Exposing internal cursors. Clients start parsing them, and you can never change the format. Keep them opaque.
- Counting everything on every request. The count query can cost more than the page.
- Forgetting the end of the list. Return an explicit signal, such as
has_more: falseor an emptynext_cursor.
Pagination in performance testing
Pagination bugs usually show up under load, not in a single request. A load test that only hits page 1 will miss the slow deep-page queries real clients and crawlers trigger. Include a spread of pages, a worst-case deep page and the maximum page size in your scenarios, and watch the 95th percentile response time rather than the average. Afterwards, keep an eye on those endpoints with API monitoring; free checks run every 5 minutes. Start with free API checks. For related reading, see API performance metrics, common API performance issues and how to load test RESTful APIs.
FAQ: API Pagination
Should I use offset or cursor pagination?
Use cursor or keyset pagination for large tables, live feeds and anything users scroll through. Offset or page numbers are fine for small lists where people jump to a page, such as an admin table.
How do I implement cursor pagination?
Sort by an indexed, unique key. Return the key of the last item as an encoded cursor. On the next request, decode it and query for rows after that key with the same sort order.
How do I return a total count with cursors?
Run a separate count query, return an estimate, or leave the total out. Many cursor-based APIs deliberately do not expose it because exact counts are expensive.
What page size should I allow?
Pick a modest default and a documented maximum. Typical maximums are around 100, which is what GitHub uses for most endpoints. Larger pages mean slower, heavier responses.
Should pagination parameters go in the query string or the body?
The query string for GET requests, because it is idiomatic and cacheable. A body is acceptable for POST-based searches with complex filters.
Do I need both next and previous cursors?
Forward-only is enough for most feeds. Supporting previous pages means a reversed query and extra edge cases.
Related terms
- What is API Security?
- What is API Sprawl? Definition, Causes, How to Fix It
- What Is API Threat Hunting?
- What is a Bearer Token? Definition, JWT and Security
- What Is the curl Command? Examples, Flags, HTTP Requests
- What is the History API?
- What is Idempotency? HTTP Methods, Idempotency Keys, Examples
- What is JSON? Beginner's Guide with Syntax, Examples
Related LoadFocus Tools
Put this concept into practice with LoadFocus, the same platform that powers everything you just read about.