|$ curl https://forge-ai.dev/api/markdown?path=docs/backend/pagination
$cat docs/pagination.md
updated Recently·22 min read·published

Pagination

BackendAPIIntermediate🎯Free Tools
Introduction

Pagination is the technique of splitting a large dataset into discrete pages. Without it, list endpoints become slower as data grows, eventually timing out or exhausting memory. Pagination keeps responses small, improves perceived performance, and reduces load on databases and clients.

There is no single best pagination strategy. Offset pagination is simple but performs poorly on deep pages. Cursor pagination is fast and stable but hard to jump to arbitrary pages. Keyset and search-after pagination offer additional flexibility for sorted datasets.

This guide explains each approach, when to use it, and how to implement it in SQL and application code. Choosing the right strategy early prevents painful migrations later.

Offset Pagination

Offset pagination uses LIMIT and OFFSET to retrieve a slice of results. Clients request a page number and a page size, and the server translates these into an offset. It is the easiest strategy to implement and understand.

The main problem with offset pagination is performance. To return rows one thousand through one thousand and twenty, the database must scan and discard the first one thousand rows. As offset grows, query time grows linearly. Offset pagination also suffers from drift: if a row is inserted or deleted while a client pages, rows may be skipped or duplicated.

offset-pagination.ts
TypeScript
1interface OffsetPaginationParams {
2 page: number;
3 perPage: number;
4}
5
6async function listUsersOffset(params: OffsetPaginationParams) {
7 const page = Math.max(1, params.page);
8 const perPage = Math.min(100, Math.max(1, params.perPage));
9 const offset = (page - 1) * perPage;
10
11 const [users, total] = await Promise.all([
12 db.user.findMany({
13 orderBy: { createdAt: "desc" },
14 skip: offset,
15 take: perPage,
16 }),
17 db.user.count(),
18 ]);
19
20 return {
21 data: users,
22 meta: {
23 page,
24 perPage,
25 total,
26 totalPages: Math.ceil(total / perPage),
27 },
28 };
29}

warning

Avoid offset pagination for large datasets or deep page navigation. Use it only when users genuinely need to jump to arbitrary pages and the dataset is small.
Cursor Pagination

Cursor pagination returns a pointer to a specific row, usually encoded as an opaque string. The client sends the cursor from the previous page to request the next page. Because the database can use an index to find the starting row, performance remains constant regardless of depth.

Cursors are stable against insertions and deletions as long as the sort key is unique. If the sort key can collide, append a unique tiebreaker such as the primary key. The cursor typically contains the last seen sort value and tiebreaker, encoded with base64.

cursor-pagination.ts
TypeScript
1interface CursorPaginationParams {
2 cursor?: string;
3 limit: number;
4}
5
6interface UserCursor {
7 createdAt: string;
8 id: string;
9}
10
11function encodeCursor(cursor: UserCursor): string {
12 return Buffer.from(JSON.stringify(cursor)).toString("base64url");
13}
14
15function decodeCursor(cursor: string): UserCursor {
16 return JSON.parse(Buffer.from(cursor, "base64url").toString("utf-8"));
17}
18
19async function listUsersCursor(params: CursorPaginationParams) {
20 const limit = Math.min(100, Math.max(1, params.limit));
21
22 const users = await db.user.findMany({
23 orderBy: [{ createdAt: "desc" }, { id: "desc" }],
24 take: limit + 1,
25 ...(params.cursor && {
26 skip: 1,
27 cursor: { id: decodeCursor(params.cursor).id },
28 where: {
29 OR: [
30 { createdAt: { lt: decodeCursor(params.cursor).createdAt } },
31 {
32 createdAt: decodeCursor(params.cursor).createdAt,
33 id: { lt: decodeCursor(params.cursor).id },
34 },
35 ],
36 },
37 }),
38 });
39
40 const hasMore = users.length > limit;
41 const data = hasMore ? users.slice(0, -1) : users;
42 const nextCursor = hasMore
43 ? encodeCursor({
44 createdAt: data[data.length - 1].createdAt.toISOString(),
45 id: data[data.length - 1].id,
46 })
47 : null;
48
49 return { data, nextCursor, hasMore };
50}

best practice

Encode cursors opaquely so clients do not depend on the internal format. Include a signature or version if you want to detect tampering or support cursor format migrations.
Keyset Pagination

Keyset pagination is the SQL implementation of cursor pagination. Instead of an offset, the query filters on the sort columns. For example, to get the next page after the row with sort value forty-two, you filter for rows where the sort value is greater than forty-two.

Keyset performs well because the database can use an index on the sort column. It also handles concurrent modifications gracefully. The tradeoff is that jumping to an arbitrary page is not possible; navigation is strictly sequential.

keyset-pagination.sql
SQL
1-- Offset approach: slow on deep pages
2SELECT id, name, created_at
3FROM users
4ORDER BY created_at DESC, id DESC
5LIMIT 20 OFFSET 10000;
6
7-- Keyset approach: constant time
8SELECT id, name, created_at
9FROM users
10WHERE (created_at, id) < ('2026-01-15 08:00:00', 'usr_9999')
11ORDER BY created_at DESC, id DESC
12LIMIT 20;

info

Ensure the sort key has a unique tiebreaker, usually the primary key. Without it, rows with identical sort values can be skipped or repeated across pages.
Search-After Pagination

Search-after pagination is a generalization of keyset pagination used by search engines such as Elasticsearch and OpenSearch. The client provides the sort values of the last result, and the search returns the next set. It works with multiple sort fields and relevance scores.

Search-after is preferred over deep offset scrolling in search because scoring and sorting can be expensive. It is also the only practical way to paginate sorted results in distributed search indices.

search-after.json
JSON
1{
2 "query": { "match": { "title": "kubernetes" } },
3 "sort": [
4 { "published_at": "desc" },
5 { "_id": "asc" }
6 ],
7 "size": 20,
8 "search_after": [1690123456000, "doc_12345"]
9}
📝

note

For search UIs, combine search-after with a shallow offset for the first few pages. Users expect page numbers for initial results, but deep navigation should switch to cursors.
Strategy Comparison
StrategyPerformanceJump to PageStabilityBest For
OffsetDegrades with depthYesDrift on changesSmall datasets, admin tables
CursorConstantNoStableFeeds, timelines, mobile lists
KeysetConstantNoStableSQL-sorted lists
Search-afterConstantNoStableSearch engines, relevance

best practice

Default to cursor pagination for public list endpoints. Use offset only when the dataset is small or when users genuinely need random access. Never allow unbounded list endpoints.
API Response Shape

A consistent pagination response shape makes client code simpler. Include the data, cursors or page metadata, and a flag indicating whether more results are available. Clients should not need to infer state from the data array length.

pagination-response.json
JSON
1{
2 "data": [
3 { "id": "usr_10", "name": "Alice", "createdAt": "2026-01-15T08:00:00Z" },
4 { "id": "usr_11", "name": "Bob", "createdAt": "2026-01-14T18:30:00Z" }
5 ],
6 "pagination": {
7 "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAxLTE0VDE4OjMwOjAwWiIsImlkIjoidXNyXzExIn0",
8 "prev_cursor": null,
9 "has_more": true,
10 "per_page": 20
11 }
12}

info

Provide both next_cursor and prev_cursor when bidirectional navigation is useful. For simple infinite scroll, next_cursor and has_more are sufficient.
Common Pitfalls

Pagination seems simple but hides several traps. Unbounded endpoints crash under load. Deep offset queries slow down unexpectedly. Cursors built on non-unique columns skip rows when values collide. And list endpoints without authorization filtering expose data that belongs to other users.

Always enforce a maximum page size, even with cursors. A client requesting ten thousand items per page can still overwhelm your database. Validate and clamp limit parameters before executing the query. Apply the same authorization filters used for single-resource lookups to list queries so users only see rows they are allowed to see.

PitfallImpactFix
Unbounded listsMemory and timeout failuresDefault and maximum page size
Deep offsetsLinear query slowdownSwitch to cursor pagination
Non-unique cursorsSkipped or duplicated rowsAdd primary key tiebreaker
Missing auth filtersData leakageFilter lists by ownership or scope

danger

A paginated list endpoint is only as secure as its filters. Never return all rows and trust the client to hide what the user should not see. Apply authorization at the database query level.
$Blueprint — Engineering Documentation·Section ID: BE-09·Revision: 1.0