API Design

Pagination

Every collection endpoint needs a limit. Pagination turns an unbounded query into a predictable one, and the choice between offset and cursor decides whether it stays fast.

intermediate14 min readUpdated Sep 16, 2026
pagination.ts
ts
// pagination.ts
export type Page<T> = {
  data: T[];
  pagination: {
    nextCursor: string | null;
    hasMore: boolean;
    total?: number;
  };
};

export type Cursor = {
  createdAt: string;
  id: string;
};
Default page size
20 items
Maximum page size
100 items
Recommended style
Keyset or cursor
Cursor format
Opaque base64url
Sort tie-breaker
Always id
Total count
Optional and costly

Why it matters

Why every list endpoint needs a limit

Bounded work per request

A limit turns a query whose cost grows with the table into one whose cost grows with the page, so latency stays flat as data accumulates.

Stable under writes

A cursor points at a row rather than a position, so inserts and deletes elsewhere in the result set cannot shift the window underneath the reader.

A contract clients can trust

A consistent envelope with data, nextCursor and hasMore lets web, mobile and infinite-scroll clients share one predictable pattern.

The big picture

The three ideas behind a page

Bound the work, order it stably, and hand the client a token that says exactly where the next page begins.

Bounds

Limit

Every query carries a page size, so no request can read, serialize or return more rows than the server allows.

Ordering

Stabilise

A total order with a unique tie-breaker is what makes a cursor meaningful and prevents skipped or duplicated rows.

Cursor

Seek

A keyset predicate lets the database seek directly to the next row through an index instead of counting past skipped ones.

At a glance

What a page must answer

limit and offset

Ask for a window of rows by position. Simple, familiar, and slow on deep pages.

Cursor

An opaque token that encodes the last row of the previous page.

Keyset predicate

WHERE (sort_key, id) < (?, ?) seeks straight to the next page via an index.

Stable sort

Always add a unique column such as id as the final sort key.

Filtering

Filters narrow the set before pagination, and the cursor must respect them.

hasMore

Fetch limit plus one to learn whether another page exists without counting.

Data model

The paginated response envelope

One shape for every collection endpoint, whether the client uses page numbers, cursors or infinite scroll.

The paginated response envelopeJSON response
  • dataarrayThe page of records, never longer than the requested limit
  • pagination.nextCursorstring | nullOpaque token to pass back as ?cursor= for the next page; null on the last page
  • pagination.hasMorebooleanTrue when at least one record exists beyond this page; derived from the limit-plus-one fetch
  • pagination.totalnumber?Optional total count, expensive on large tables, so omit it or serve it from a cache

One shape for every collection endpoint, whether the client uses page numbers, cursors or infinite scroll.

Flow

How a cursor page is served

The cursor is decoded, used as a keyset predicate, and replaced with a fresh token for the next page.

  1. 1

    Client sends limit and cursor

    The request carries a page size and, for every page after the first, the opaque cursor from the previous response.

  2. 2

    Server decodes the cursor

    Base64url is decoded and validated. A malformed or tampered cursor is rejected before it reaches the database.

  3. 3

    Query with a keyset WHERE

    The decoded sort values become a row comparison against the indexed sort columns, with the same ORDER BY as the first page.

  4. 4

    Fetch limit plus one

    Ask for one more row than requested. Its presence proves another page exists without running a count.

  5. 5

    Build the next cursor

    Encode the sort key and id of the last returned row into a fresh opaque token.

  6. 6

    Return data and nextCursor

    Send the page in the envelope. The cursor is null when there are no more rows, which is how clients know to stop.

The complete guide

Pagination: Everything you need to know

Why every collection must be paginated

A list endpoint without a limit is a promise you cannot keep. The code may be correct on the day it ships, when the table holds a few hundred rows, and quietly become a liability as the table grows. One SELECT * over a million-row table will serialize tens of megabytes, hold it in memory, and stream it to a client that likely wanted the first twenty items.

The failure is not gradual. At some table size the endpoint crosses a threshold: memory spikes, the event loop stalls, requests time out, retries pile on, and a single slow route takes the whole service down with it. An unauthenticated attacker does not even need a bug, only a URL.

The fix is a hard bound on every collection:

  • A default page size so clients get a useful response without thinking about it.
  • A maximum page size so no client can ask for everything.
  • A stable order so pages do not overlap or skip.
  • A continuation token so the client can ask for the next slice.

Even endpoints you believe are small deserve a limit. Tables grow, imports happen, and the endpoint you protect today is the one that stays up tomorrow.

Offset pagination and where it breaks

The most familiar form is LIMIT with OFFSET: return limit rows, starting after offset rows.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

It is easy to understand, maps naturally to page numbers (page * limit), and lets a client jump to any page. For small, slowly changing tables it is perfectly adequate. For everything else it has three problems that get worse as the data grows.

Deep pages are expensive. To return the rows after an offset, the database must first produce and discard every row before it. OFFSET 1000000 reads a million rows to return twenty. The work grows linearly with the page number, so page 1 is fast and page 50,000 is not.

The window drifts. Offset is a position in a list that is changing. If a row is inserted before your position, the next page repeats an item; if a row is deleted, an item is skipped. The client sees duplicates and holes, and no amount of retrying fixes it.

The order may be unstable. If the sort key has ties, as created_at often does, the database is free to return tied rows in any order. Two requests for the same offset can produce different results.

Offset is not wrong, it is just unsuited to large or fast-moving collections. Use it for admin tables with page numbers over modest data, and reach for a cursor when the list can grow.

Cursor pagination: stable by construction

A cursor replaces “skip N rows” with “start after this row”. The client sends an opaque token that the server produced, and the server turns it back into a precise position in the sort.

Because the token names a row rather than a count, inserts and deletes elsewhere in the result set cannot shift it. Because the server seeks directly to that row, depth costs nothing extra. The two guarantees — stability and constant cost — are exactly the ones offset cannot provide.

The price is that a cursor only moves forward or backward. There is no “page 50”. For feeds, timelines, exports and infinite scroll, that is no loss at all. For a search results grid with numbered pages, offset remains the natural fit until the data gets large.

A cursor should be opaque to clients: base64url-encoded, treated as a black box, and returned unchanged. Opacity lets the server change the sort columns later without breaking clients, and it discourages hand-crafted tokens.

Keyset pagination in SQL

Keyset pagination is what a cursor does at the database level. Instead of counting, you compare the sort columns against the values of the last row you returned.

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

The (created_at, id) < ($1, $2) form is a row-value comparison, and it expresses the logic cleanly: take rows whose sort key comes strictly after the last one. Postgres and most relational databases can use an index that matches the ordering to seek directly to the starting point.

That index is not optional. Keyset pagination is only fast when the sort is backed by an index over the exact columns and directions used in ORDER BY.

CREATE INDEX posts_created_id_idx
  ON posts (created_at DESC, id DESC);

Two details cause most bugs. First, the comparison direction must match the sort: a DESC sort uses <, an ASC sort uses >. Second, the cursor must include every sort column. If you sort by created_at alone, ties make the cursor ambiguous, which is why id is always appended.

Building and encoding a cursor

A cursor is just the sort values of the last row on the page, serialized and encoded. Keep it small and validate it on the way back in.

export type Cursor = { createdAt: string; id: string };

export function encodeCursor(cursor: Cursor): string {
  return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}

export function decodeCursor(token: string): Cursor {
  let parsed: unknown;
  try {
    parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
  } catch {
    throw new Error("invalid_cursor");
  }

  const value = parsed as Record<string, unknown>;
  if (typeof value.createdAt !== "string" || typeof value.id !== "string") {
    throw new Error("invalid_cursor");
  }
  return { createdAt: value.createdAt, id: value.id };
}

The limit-plus-one trick tells you whether another page exists without a count. Fetch limit + 1 rows; if you get more than limit, there is a next page, and you drop the extra row before responding.

const rows = await db.query(
  `SELECT id, title, created_at
   FROM posts
   WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1, $2))
   ORDER BY created_at DESC, id DESC
   LIMIT $3`,
  [cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1],
);

const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const last = data.at(-1);
const nextCursor = hasMore && last
  ? encodeCursor({ createdAt: last.created_at, id: last.id })
  : null;

Base64url is an encoding, not a signature. A client can decode it and craft a new one, so never treat a cursor as trusted input. Validate every field, and if tampering matters, sign the payload with an HMAC or keep the sort keys server-side and store the cursor in Redis.

The paginated response envelope

Return one consistent shape from every collection endpoint. Clients then have a single pattern to parse, and you can evolve the internals without changing the contract.

{
  "data": [
    { "id": "post_1042", "title": "Hello", "createdAt": "2026-09-16T10:00:00Z" }
  ],
  "pagination": {
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE2VDEwOjAwOjAwWiIsImlkIjoicG9zdF8xMDQyIn0",
    "hasMore": true,
    "total": 1284
  }
}

data holds the page and never exceeds the limit. nextCursor is the token for the following page, or null when the list is exhausted, which is the signal for infinite scroll to stop. hasMore is a convenience that saves clients from checking for null, and it falls out of the limit-plus-one fetch for free.

total is optional and should be treated as such. A cursor page does not need it, and computing it on every request is often more expensive than the page itself. Include it only when the UI genuinely shows “1,284 results”, and then cache it or approximate it.

Sorting stability and tie-breakers

A cursor is only meaningful if the sort is a total order: for any two rows, one is definitively before the other. Most natural sort keys are not. Many posts share the same created_at, and the database may return them in any order, so a cursor that encodes only the timestamp can skip or repeat rows.

The fix is to append a unique column, almost always the primary key, as the final sort key.

ORDER BY created_at DESC, id DESC

Now the order is deterministic, and the cursor (created_at, id) is unique. The same rule applies to any sort: ORDER BY score DESC, id DESC, ORDER BY name ASC, id ASC. The tie-breaker must be unique, and it must be part of both the index and the cursor.

The sort keys must also be stable over time. Sorting by updated_at and using it in a cursor is a trap: when a row is edited, its sort position moves, and a cursor captured before the edit can point into the wrong place. Prefer immutable keys such as created_at or a monotonic id, and if you must sort by a mutable field, accept that cursors may become stale.

Total counts are expensive

A total count is the most requested and least necessary part of pagination. SELECT count(*) with a filter must examine every matching row, and on a large table that can take longer than fetching the page.

-- Runs on every request if you are not careful.
SELECT count(*) FROM posts WHERE author_id = $1;

A cursor page does not need it. hasMore answers the question the client actually has — “is there more?” — without touching a single extra row. If the UI truly needs a number, choose an approach that fits the accuracy it can tolerate:

  • Omit it. Most infinite-scroll and feed interfaces never show a total.
  • Approximate it. Postgres exposes reltuples on pg_class and the planner can estimate with EXPLAIN; both are fast and wrong by a few percent.
  • Cache it. Compute the count on a schedule or after writes and serve the stored value.
  • Maintain a counter. Keep a running count in a summary table updated inside the same transaction as the writes.
  • Cap it. Stop counting at 1,000 and return “1000+”, which bounds the cost.

Whatever you pick, do not run an uncached count(*) on every page request of a large table.

Page size: defaults and limits

Two numbers protect the server: a default when the client says nothing, and a hard maximum when the client asks for too much.

const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;

function parseLimit(raw: string | undefined): number {
  const requested = Number.parseInt(raw ?? "", 10);
  if (!Number.isFinite(requested) || requested < 1) return DEFAULT_LIMIT;
  return Math.min(requested, MAX_LIMIT);
}

Clamp rather than reject. A client asking for limit=1000 should receive 100 rows and a cursor, not a 400 error that forces it to guess your rules. Validate that limit is a positive integer and never pass a client string straight into SQL.

Page size is a latency dial. Larger pages mean fewer round-trips but more work per request and more bytes on the wire. For interactive UIs, 20 to 50 is usually right. For bulk exports, use a dedicated endpoint with a much larger cap and streaming, rather than raising the limit on the interactive route.

Filtering and sorting parameters

Pagination composes with filtering and sorting, but both must be handled carefully because they change the meaning of a cursor.

GET /posts?author_id=42&sort=-created_at&limit=20&cursor=eyJjcmVhdGVkQXQiOi...

Whitelist sort fields. Never interpolate a client-supplied column name into SQL. Map an allowed set of names to expressions, and pick the direction from an explicit prefix or parameter.

const SORTS = {
  created_at: "created_at",
  title: "title",
  score: "score",
} as const;

const column = SORTS[sortField] ?? "created_at";
const direction = order === "asc" ? "ASC" : "DESC";

Make the cursor match the sort. If the sort changes, the cursor is meaningless. Either encode the sort into the cursor and reject a mismatch, or include the sort in the cursor’s payload and verify it on decode. The same goes for filters: a cursor from an unfiltered list should not be replayed against a filtered one.

Add every sort column to the index. A composite index on (author_id, created_at DESC, id DESC) serves the filter and the keyset seek in one structure, which is the difference between a page in a millisecond and a page in a second.

Pagination over search and aggregations

Search engines and aggregations have their own rules. Full-text search backends usually cap from + size at around ten thousand results, because deep offset is expensive for them too. The equivalent of keyset there is a search_after token built from the sort values of the last hit.

POST /posts/_search
{
  "size": 20,
  "sort": [{ "created_at": "desc" }, { "id": "desc" }],
  "search_after": ["2026-09-16T10:00:00Z", "post_1042"]
}

Aggregations are best returned separately from the page. Computing facet counts over every match on every request is the same trap as count(*). Either compute them once and cache them, or expose a dedicated endpoint the UI calls when the user opens a filter panel.

For SQL aggregates, the same keyset idea applies: order by a stable aggregate key such as a date bucket or an id, and use that key in the cursor. Do not paginate a GROUP BY with OFFSET over a large table; materialize the aggregate first and paginate the materialized result.

Choosing a strategy

Most teams only need one rule: if the collection can grow large or change while it is being read, use a cursor; if it is small, slow-moving and shown as a numbered grid, offset is fine.

Small table, numbered UI          -> offset
Large or fast-changing collection -> keyset cursor
Infinite scroll or mobile feed    -> keyset cursor
Search results                    -> search_after token
Bulk export                       -> dedicated streaming endpoint

Offset and cursor can coexist. An admin table might offer page numbers for browsing and a cursor for an “export all” flow. What matters is that each endpoint picks one style and documents it, rather than mixing page and cursor parameters in a way clients cannot predict.

Backward pagination

Forward pagination gets all the attention, but many interfaces also need a previous button. The technique is to reverse the comparison and the ordering, fetch one page, then reverse the rows in application code before returning them.

async function pageBackward(prev: Cursor, limit: number) {
  const rows = await db.query(
    `SELECT id, title, created_at
     FROM posts
     WHERE (created_at, id) > ($1, $2)
     ORDER BY created_at ASC, id ASC
     LIMIT $3`,
    [prev.createdAt, prev.id, limit + 1],
  );

  // Reverse back into the canonical descending order.
  return rows.reverse();
}

Return a prevCursor built from the first row of the current page alongside nextCursor built from the last. A client walking backward flips to the forward direction when the user returns to scrolling down, so the cursors must be interchangeable rather than tied to a direction.

Keeping cursors honest

Base64url is an encoding, not a signature. A client can decode a cursor, edit it, and send it back, so a cursor is untrusted input exactly like a query parameter. Validate every field on decode, and reject anything that does not fit the expected shape before it reaches SQL.

If tampering is a real concern — for example, if a cursor carries a tenant id — sign it with an HMAC and verify the signature in constant time.

import { createHmac, timingSafeEqual } from "node:crypto";

const secret = process.env.CURSOR_SECRET!;

export function signCursor(payload: string): string {
  const mac = createHmac("sha256", secret).update(payload).digest("base64url");
  return `${Buffer.from(payload).toString("base64url")}.${mac}`;
}

export function verifyCursor(token: string): string {
  const [encoded, mac] = token.split(".");
  const expected = createHmac("sha256", secret).update(encoded).digest("base64url");
  const a = Buffer.from(mac);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error("invalid_cursor");
  }
  return Buffer.from(encoded, "base64url").toString("utf8");
}

An alternative is to keep cursors fully server-side: store the position in Redis under a random id and hand the client only that id. It hides the sort columns completely and allows expiry, at the cost of a lookup on every page.

A client that follows cursors

A cursor is designed to be followed, so the client code is a simple loop: request a page, append the data, and continue while nextCursor is not null. There is no page arithmetic and no risk of skipping a page.

async function fetchAll<T>(path: string): Promise<T[]> {
  const items: T[] = [];
  let cursor: string | null = null;

  do {
    const url = new URL(path, "https://api.example.com");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url);
    if (!res.ok) throw new Error(`request_failed_${res.status}`);

    const page = await res.json();
    items.push(...page.data);
    cursor = page.pagination.nextCursor;
  } while (cursor);

  return items;
}

The loop terminates on nextCursor === null, which is why that field must be reliably set on the final page. For infinite scroll, the same pattern runs one page at a time as a sentinel element enters the viewport, and the token is kept in component state rather than the URL.

Pagination and the query plan

Keyset pagination is fast only when the database can use an index. Always confirm it with EXPLAIN ANALYZE rather than assuming.

EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-09-16T10:00:00Z', 'post_1042')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Limit  (cost=0.43..8.94 rows=20 width=40)
       (actual time=0.021..0.058 rows=20 loops=1)
  ->  Index Scan Backward using posts_created_id_idx on posts
        (cost=0.43..521.10 rows=417 width=40)
        (actual time=0.019..0.051 rows=20 loops=1)
        Index Cond: (ROW(created_at, id) < ROW('2026-09-16T10:00:00Z'::timestamptz, 'post_1042'))
Execution Time: 0.081 ms

The Index Scan Backward with an Index Cond is the result you want: the database seeks to the cursor position and stops after twenty rows. A Seq Scan with a Filter means the index does not match the sort, and the query is scanning the whole table on every page. The index must list the same columns in the same order and directions as ORDER BY, with the tie-breaker last.

Testing pagination

The properties worth testing are stability and termination, not just the happy path. A test that walks every page and asserts no duplicates and no missing rows catches the subtle tie-breaker bugs that are otherwise invisible.

test("cursor pagination never repeats or skips rows", async () => {
  const seen = new Set<string>();
  let cursor: string | null = null;

  do {
    const page = await request(app)
      .get("/posts")
      .query({ limit: 10, cursor: cursor ?? undefined })
      .expect(200);

    for (const post of page.body.data) {
      expect(seen.has(post.id)).toBe(false);
      seen.add(post.id);
    }
    cursor = page.body.pagination.nextCursor;
  } while (cursor);

  expect(seen.size).toBe(totalPosts);
});

test("rejects a malformed cursor", async () => {
  await request(app).get("/posts?cursor=not-a-cursor").expect(400);
});

Also test the boundaries: the first page with no cursor, the last page where nextCursor is null, a page larger than the maximum, and a sort that changes mid-walk. The last one is the test that proves your tie-breaker works.

Best practices

  • Give every collection a default limit and a hard maximum, and clamp rather than reject.
  • Prefer keyset or cursor pagination for anything that grows or changes.
  • Always sort with a unique tie-breaker such as id, and include it in the index and the cursor.
  • Keep cursors opaque, encode them as base64url, and validate every field on decode.
  • Fetch limit + 1 to determine hasMore instead of running a count.
  • Return a consistent envelope with data, nextCursor and hasMore everywhere.
  • Treat total as optional; omit, approximate or cache it.
  • Whitelist sort fields and directions, and bind all values as parameters.
  • Make the cursor encode the sort and filter context so a stale token cannot be replayed.

Common mistakes

  • Shipping a list endpoint with no limit and discovering it at scale.
  • Using OFFSET for deep pages and watching latency grow with the page number.
  • Sorting on a non-unique column without a tie-breaker, so rows repeat and vanish.
  • Sorting by a mutable column and treating the cursor as permanent.
  • Passing a client-provided limit, column name or direction straight into SQL.
  • Running an uncached count(*) on every page request.
  • Exposing raw internal ids or timestamps in a cursor and calling it secure.
  • Returning a bare array with no cursor, forcing clients to guess how to continue.
  • Allowing a limit of a million because the UI never asks for it.

Where to go next

Pagination is part of designing a predictable API, so the REST guide is the natural companion for resource shapes, status codes and query conventions. If you want to stop recomputing the same page, Caching covers serving it from memory, and Connection Pooling keeps each paged query cheap on the database. To make those queries fast in the first place, PostgreSQL explains the composite indexes and query plans that keyset pagination depends on.

In practice

Offset, keyset, cursor, envelope

The query shapes and the payload they produce.

queries/offset.sql
-- Page 3 of 20. The database still walks the first 40 rows.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

-- Deep pages pay for every row they skip.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

Keyset vs OFFSET for deep pages

OFFSET makes the database read and discard every skipped row. A keyset predicate seeks straight to the first row you want, so page one million costs the same as page one.

Keyset
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
OFFSET
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

-- Reads and throws away a million rows
-- before returning the twenty you asked for.

Stable cursor vs page number

A page number describes a position that rows can move through. A cursor describes the row you stopped at, so new and deleted rows cannot shift the window.

Cursor
GET /posts?limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Page number
GET /posts?limit=20&page=2

# A row inserted between requests shifts every
# later page, so one item is skipped and another
# is shown twice.

Trade-offs

Cursor or offset?

Offset is easier to build and reason about. Cursor is what keeps a growing table fast and consistent.

Strengths

  • Cost does not grow with depth

    A keyset query seeks through the index, so the millionth page costs roughly what the first page costs instead of scanning everything before it.

  • Results stay stable

    Because the cursor names a row, concurrent inserts and deletes cannot cause items to be skipped or repeated the way they do with page numbers.

  • It composes with filters

    Any WHERE clause narrows the set, and the cursor simply carries the sort values of the last row, so filtering and pagination fit together cleanly.

Trade-offs

  • No random access

    Clients cannot jump to page 50. They move forward and sometimes backward, which is fine for feeds but awkward for a numbered results grid.

  • Sorting is constrained

    Every sort column must be part of the cursor and stable over time. Sorting by a mutable field such as updated_at breaks cursors as rows change.

  • Cursors are not user-friendly

    An opaque token cannot be hand-written or bookmarked meaningfully, so debugging and shareable links need extra care.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Pagination?

Our interactive tutorial walks you through Pagination step by step — with quizzes and real code you can run in the browser.