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

StrategyRequest shapeBest for
Offset and limit?offset=200&limit=50Small datasets, jump-to-page screens
Page number?page=5&per_page=50Same as offset with a friendlier URL
Cursor?cursor=abc123&limit=50Feeds, infinite scroll, changing data
Keyset (seek)?after_id=12345&limit=50Large 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.

PageRows to step past plus return (offset)Rows to step past plus return (keyset)
15050
1050050
1005,00050
1,00050,00050
10,000500,00050

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=100000 on 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=1000000 and 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: false or an empty next_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.

How fast is your website?

Elevate its speed and SEO seamlessly with our Free Speed Test.

Free Website Speed Test

Analyze your website's load speed and improve its performance with our free page speed checker.