API Pagination Best Practices: Offset, Cursor, Keyset

API pagination best practices: when offset, keyset or cursor pagination fits, how each fails, and how to model pages in OpenAPI and the Link header.

On this page

Paginate every collection endpoint, cap the page size on the server, and sort by a unique, stable key. Use offset pagination (?limit=25&offset=50) when the data set is small, rarely changes, and users need to jump to page 7. Use keyset pagination, wrapped in an opaque cursor (?cursor=...), for anything large or frequently written: feeds, logs, orders, events. Keyset queries stay fast at any depth and do not skip or repeat rows when records are inserted between requests. Return the next cursor in the body, a Link header with rel="next" if you want generic clients to follow it, and document both in the OpenAPI spec.

The pagination glossary entry defines the terms. This guide is about choosing between the three methods and the failure modes that decide it.

Offset pagination and its two failure modes

Offset pagination maps directly to SQL. Page 3 with 25 rows per page is OFFSET 50 LIMIT 25. Clients like it because they can compute any page number and show "page 3 of 40". It fails in two ways, and both get worse as the table grows.

SQLoffset.sql
SELECT id, created_at, total
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 25 OFFSET 50;

The first failure is cost. The PostgreSQL documentation states that rows skipped by an OFFSET clause still have to be computed inside the server, so a large OFFSET might be inefficient. Page 4,000 makes the database produce and throw away 99,975 rows to return 25. Response time grows with page depth, and a crawler or a script that walks every page pays that cost on every request.

The second failure is drift. Offsets count positions, and positions move when rows are inserted or deleted. Suppose a client reads page 1 of orders sorted newest first, rows 1 to 25. Before it asks for page 2, three new orders arrive. They take positions 1 to 3, and everything else shifts down by three. Page 2, which starts at offset 25, now begins with the three orders that were rows 23 to 25 on page 1. The client shows them twice. Deletes cause the opposite problem: rows shift up and the client skips them without any error. For an export or a sync job, a silently skipped row is a data bug.

Whatever method you use, the sort order must be unique. PostgreSQL warns that LIMIT and OFFSET give inconsistent results across pages unless ORDER BY constrains the rows into a unique order, because the planner can pick different plans for different LIMIT values. Sorting by created_at alone is not enough when two orders share a timestamp. Add id as a tie-breaker, as in the query above.

Keyset pagination: continue from the last row

Keyset pagination, sometimes called seek pagination, asks for rows after the last one the client saw, using the sort key values instead of a position. With an index on (created_at, id), the database can seek straight to the starting point, so page 4,000 costs about the same as page 1. New rows inserted at the top do not shift anything, because the next page is defined by values, not by how many rows came before.

SQLkeyset.sql
-- $1 and $2 are created_at and id of the last row on the previous page
SELECT id, created_at, total
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 26;
Ask for one row more than the page size. If 26 rows come back, return 25 and a next cursor; if fewer, this is the last page.

The row comparison (created_at, id) < ($1, $2) is lexicographic. PostgreSQL compares the elements left to right and stops at the first unequal pair, which is exactly "older timestamp, or same timestamp and smaller id". Databases without row value comparison need the expanded form: created_at < $1 OR (created_at = $1 AND id < $2). Two details trip people up. The sort columns should be NOT NULL, because a null in the pair makes the comparison unknown and that row never matches. And the comparison direction must follow the sort direction: descending order uses less-than, ascending uses greater-than.

Keyset has real limits. A client cannot jump to page 40; it can only move forward, or backward if you also implement the reverse query. Every sort option needs its own index and its own comparison, so an endpoint that lets users sort by any of eight columns gets expensive to support. And there is no cheap total count; if you need one, compute it separately and expect it to be approximate on large tables.

Cursor pagination: keyset behind an opaque token

A cursor is a token the server hands out and the client returns unchanged. Most cursor implementations are keyset pagination underneath: the token encodes the last row's sort values. The difference is the contract. With ?after_created_at=...&after_id=..., clients learn your sort columns and depend on them. With ?cursor=eyJ..., they depend on nothing, and you can add a tie-breaker column, change the sort, or move storage later without a breaking change.

  • Encode the sort values and the filters the page was built with, for example base64url of {"c":"2026-10-01T09:12:44Z","i":"ord_8812","status":"paid"}. A cursor reused with a different status filter would return wrong rows, so reject it with 400.
  • Treat the token as untrusted input. Decode it, validate every field, and use parameterized queries. If clients must not tamper with it, sign it with an HMAC.
  • Decide whether cursors expire. Keyset cursors can stay valid indefinitely; cursors that point into a server-side snapshot cannot, and the docs should say how long they last and what error an expired one returns.
  • Return null, not an empty string, as the next cursor on the last page, so clients can loop with while (cursor !== null).
OffsetKeysetCursor (opaque keyset)
Request?limit=25&offset=50?limit=25&after_id=ord_8812?limit=25&cursor=eyJj...
Cost at depthGrows with offsetFlat with a matching indexFlat with a matching index
Inserts between requestsDuplicates rowsStableStable
Deletes between requestsSkips rowsStableStable
Jump to page NYesNoNo
Change sort internals laterYesBreaking for clientsYes
Good fitSmall admin tables, search result pagesInternal servicesPublic APIs, feeds, sync and export jobs

RFC 8288 defines the Link header: a target URI in angle brackets followed by parameters such as rel, with several links separated by commas. The relation types next, prev, first and last are registered with IANA, so a generic HTTP client can follow pages without knowing your body format.

HTTPpage-response.http
HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.example.com/v1/orders?limit=25&cursor=eyJjIjoiMjAyNi0xMC0wMVQwOToxMjo0NFoiLCJpIjoib3JkXzg4MTIifQ>; rel="next"
Access-Control-Expose-Headers: Link

{"data": [{"id": "ord_8812", "createdAt": "2026-10-01T09:12:44Z", "total": 4200}], "nextCursor": "eyJjIjoiMjAyNi0xMC0wMVQwOToxMjo0NFoiLCJpIjoib3JkXzg4MTIifQ"}
The body is shortened to one order. On the last page, omit the next link and return nextCursor: null.

Send the cursor in the body as well. Browser code calling your API from another origin cannot read Link by default: MDN lists the CORS-safelisted response headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma), and Link is not among them. Either add Access-Control-Expose-Headers: Link, as above, or make sure the body alone is enough. Building the full next URL on the server also saves clients from reassembling filters, which is a common source of bugs when a filter is dropped on page 2.

Model cursor pagination in OpenAPI

The spec should tell a client everything it needs to walk the collection: the maximum page size, the cursor parameter, what the response looks like on the last page, and which error a bad cursor produces. Here is the orders endpoint from the examples above.

YAMLorders.yaml
openapi: 3.1.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /v1/orders:
    get:
      summary: List orders
      description: Returns orders sorted by createdAt descending, then id descending. Pass the nextCursor value from the previous page to get the next one.
      parameters:
        - name: limit
          in: query
          description: Page size. Values above 100 are rejected with 400.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          description: Opaque token from a previous response. Omit for the first page.
          schema:
            type: string
      responses:
        "200":
          description: One page of orders.
          headers:
            Link:
              description: RFC 8288 links with rel="next" when another page exists.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [data, nextCursor]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Order"
                  nextCursor:
                    type: [string, "null"]
                    description: Null on the last page.
        "400":
          description: limit is out of range or cursor is malformed or expired.
components:
  schemas:
    Order:
      type: object
      required: [id, createdAt, total]
      properties:
        id:
          type: string
        createdAt:
          type: string
          format: date-time
        total:
          type: integer

In that spec, nextCursor is required and nullable (type: [string, "null"] is the OpenAPI 3.1 form; 3.0 uses nullable: true), so clients never have to tell a missing field from the end of the list. limit has both a maximum and a default, so a client that omits it still gets a bounded page. The description states the sort order, which the schema cannot express. For a shorter snippet that shows the parameter and wrapper shape on its own, see the OpenAPI pagination example.

APIForge's API quality score gives this spec 100. That number says the operation is named, versioned, documented and has an error response; the score has no pagination rule, so it will not tell you that a list endpoint lacks a limit. Check that by hand: search the spec for every GET that returns an array and confirm each one has a capped limit and a cursor or offset parameter.

Edge cases to decide before you ship

  • Oversized limit: rejecting limit=10000 with 400 tells the client what went wrong. Silently clamping it to 100 is friendlier, but then the client must not assume a short page means the end. Pick one and document it.
  • End of data: use nextCursor: null or a missing next link as the only end signal. A page with fewer rows than limit is not reliable, because filters applied after the query can shorten any page.
  • Total counts: a COUNT(*) on a large filtered table can cost more than the page query. Offer it as an opt-in parameter, or return an estimate and say so.
  • Changed sort or filters: a cursor built for status=paid must not be accepted with status=open. Encode the filters in the cursor and return 400 on mismatch.
  • Backward paging: if clients need a previous button, return a prev cursor built from the first row of the page and run the comparison in the other direction, then reverse the rows before returning them.
  • Very deep offset: if you keep offset pagination, set a maximum offset and point clients that need everything to a cursor-based export endpoint.

To check the behavior on a live API, load the spec in the API testing workbench, send the list request, then paste the nextCursor value into the cursor parameter and send again. Insert a record between the two calls and compare the pages for duplicates. Broader resource and query design is covered in REST API design best practices, and the schema side of the wrapper object in OpenAPI response schema best practices.

Frequently asked questions

No. Offset pagination is fine for small, slowly changing data where users want page numbers, such as an admin table. Cursor pagination wins when the collection is large or written often, because it stays fast at depth and does not skip or repeat rows.

Usually the sort key values of the last row returned, plus the filters the page was built with, encoded as an opaque string. Clients should treat it as a black box and send it back unchanged. Validate it on the server like any other input.

Both work, and you can send both. The Link header with rel="next" is standard and lets generic clients follow pages. The body is easier for browser code, which cannot read Link from a cross-origin response unless the server exposes it with Access-Control-Expose-Headers.

Sources

  1. RFC 8288: Web LinkingIETF
  2. Link Relation Types registry (next, prev, first, last)IANA
  3. PostgreSQL: LIMIT and OFFSETPostgreSQL documentation
  4. PostgreSQL: Row constructor comparisonPostgreSQL documentation
  5. Access-Control-Expose-HeadersMDN Web Docs