Pagination
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 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.
| 1 | interface OffsetPaginationParams { |
| 2 | page: number; |
| 3 | perPage: number; |
| 4 | } |
| 5 | |
| 6 | async 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
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.
| 1 | interface CursorPaginationParams { |
| 2 | cursor?: string; |
| 3 | limit: number; |
| 4 | } |
| 5 | |
| 6 | interface UserCursor { |
| 7 | createdAt: string; |
| 8 | id: string; |
| 9 | } |
| 10 | |
| 11 | function encodeCursor(cursor: UserCursor): string { |
| 12 | return Buffer.from(JSON.stringify(cursor)).toString("base64url"); |
| 13 | } |
| 14 | |
| 15 | function decodeCursor(cursor: string): UserCursor { |
| 16 | return JSON.parse(Buffer.from(cursor, "base64url").toString("utf-8")); |
| 17 | } |
| 18 | |
| 19 | async 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
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.
| 1 | -- Offset approach: slow on deep pages |
| 2 | SELECT id, name, created_at |
| 3 | FROM users |
| 4 | ORDER BY created_at DESC, id DESC |
| 5 | LIMIT 20 OFFSET 10000; |
| 6 | |
| 7 | -- Keyset approach: constant time |
| 8 | SELECT id, name, created_at |
| 9 | FROM users |
| 10 | WHERE (created_at, id) < ('2026-01-15 08:00:00', 'usr_9999') |
| 11 | ORDER BY created_at DESC, id DESC |
| 12 | LIMIT 20; |
info
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.
| 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
| Strategy | Performance | Jump to Page | Stability | Best For |
|---|---|---|---|---|
| Offset | Degrades with depth | Yes | Drift on changes | Small datasets, admin tables |
| Cursor | Constant | No | Stable | Feeds, timelines, mobile lists |
| Keyset | Constant | No | Stable | SQL-sorted lists |
| Search-after | Constant | No | Stable | Search engines, relevance |
best practice
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.
| 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
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.
| Pitfall | Impact | Fix |
|---|---|---|
| Unbounded lists | Memory and timeout failures | Default and maximum page size |
| Deep offsets | Linear query slowdown | Switch to cursor pagination |
| Non-unique cursors | Skipped or duplicated rows | Add primary key tiebreaker |
| Missing auth filters | Data leakage | Filter lists by ownership or scope |
danger