{"post":{"slug":"how-to-paginate-an-api-2026","title":"How to paginate an API in 2026: offset, cursor, and keyset compared","excerpt":"Pagination done wrong causes slow queries, inconsistent results, and thundering herds. This is the 2026 guide to offset, cursor, and keyset pagination.","category":"code","date":"April 16, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">P</span>agination is one of those things every API needs and most implementations get subtly wrong. Offset pagination ships fast and breaks at scale. Cursor pagination is robust but requires a bit more design. Keyset pagination is the fastest of all but is tightly coupled to your sort order. Knowing which to reach for — and how to implement it correctly — saves you a painful migration later.\n\n## What changed in 2026\n\n- **Cursor/keyset became the default expectation.** GitHub, Stripe, and most major APIs moved to opaque cursor tokens; clients now expect this pattern.\n- **ORM support improved.** Prisma, Drizzle, and SQLAlchemy now have first-class cursor pagination helpers that generate correct `WHERE` clauses.\n- **Infinite scroll dominated mobile UX**, making append-only cursor pagination the natural fit for most list endpoints.\n- **Vector search results added a new wrinkle** — similarity search returns top-K results with no stable natural order, requiring special handling.\n\n## The three approaches compared\n\n| Approach | How it works | Best for | Breaks when |\n| --- | --- | --- | --- |\n| Offset | `LIMIT n OFFSET m` | Small tables, admin UIs, known total count needed | Table > 100k rows; concurrent inserts |\n| Cursor | Encode last-seen ID in a token | Feeds, large lists, real-time data | Complex multi-column sorts |\n| Keyset | `WHERE (created_at, id) < (?, ?)` | High-performance large datasets | Non-indexed sort columns |\n\n## Offset pagination\n\nSimple and familiar, but `OFFSET` forces the DB to read and discard `m` rows on every request:\n\n```sql\n-- Page 3, 20 items per page\nSELECT * FROM posts\nORDER BY created_at DESC\nLIMIT 20 OFFSET 40;\n```\n\n```json\n{\n  \"data\": [...],\n  \"pagination\": {\n    \"page\": 3,\n    \"pageSize\": 20,\n    \"total\": 1842\n  }\n}\n```\n\nWorks fine for admin tables with < 10k rows. Avoid for feeds or user-facing lists that grow.\n\n## Cursor pagination\n\nEncode the position as an opaque token (usually base64 of the last row's ID or sort key):\n\n```typescript\n// Encode cursor\nfunction encodeCursor(id: string): string {\n  return Buffer.from(id).toString('base64url');\n}\n\nfunction decodeCursor(cursor: string): string {\n  return Buffer.from(cursor, 'base64url').toString();\n}\n\n// Query\nasync function listPosts(cursor?: string, limit = 20) {\n  const where = cursor\n    ? { id: { lt: decodeCursor(cursor) } }\n    : {};\n\n  const posts = await db.posts.findMany({\n    where,\n    orderBy: { id: 'desc' },\n    take: limit + 1, // fetch one extra to know if there are more\n  });\n\n  const hasMore = posts.length > limit;\n  const items = hasMore ? posts.slice(0, limit) : posts;\n  const nextCursor = hasMore ? encodeCursor(items[items.length - 1].id) : null;\n\n  return { items, nextCursor };\n}\n```\n\nResponse format:\n\n```json\n{\n  \"data\": [...],\n  \"pagination\": {\n    \"nextCursor\": \"eyJpZCI6IjEyMyJ9\",\n    \"hasMore\": true\n  }\n}\n```\n\n## Keyset pagination\n\nThe fastest option — uses an index seek, zero row discard. Requires a stable, indexed sort column:\n\n```sql\n-- Next page after (created_at='2026-05-01', id=456)\nSELECT * FROM events\nWHERE (created_at, id) < ('2026-05-01T12:00:00Z', 456)\nORDER BY created_at DESC, id DESC\nLIMIT 20;\n```\n\nEnsure a composite index on `(created_at DESC, id DESC)` or performance degrades.\n\n## How to pick\n\n1. **Admin table with < 50k rows and users need \"go to page 5\"?** Offset is fine; total count is cheap.\n2. **Feed, activity stream, or any list that updates in real time?** Cursor — consistent across concurrent writes.\n3. **Very large table (millions of rows) with a clear sort column?** Keyset for maximum query performance.\n4. **GraphQL?** Use Relay-spec cursor pagination (`edges`, `node`, `pageInfo.endCursor`).\n5. **Vector / similarity search?** Return the raw ranked list with `limit`; there is no stable cursor across re-ranking.\n\n## Common mistakes\n\n**No page size cap.** Without a server-enforced maximum, a client can request `limit=100000` and crash your DB. Cap at 100 or 200.\n\n**Exposing raw database IDs in cursors.** Encode and opacify them. Even if the cursor is not sensitive, coupling clients to internal IDs makes future migrations painful.\n\n**Missing index on the cursor column.** A cursor-based query without an index on the sort column performs full table scans.\n\n**Total count on every request.** `SELECT COUNT(*)` on large tables is slow. Only compute totals when the UI genuinely needs them; omit from cursor-based endpoints.\n\n**Different sort orders on different pages.** If sort order is not deterministic (e.g. ties in `created_at` not broken by ID), the cursor can skip or repeat rows.\n\n## What to skip\n\n- **Rolling your own \"seek\" logic** without a unique tiebreaker — always include `id` as a secondary sort key.\n- **`OFFSET` beyond ~1000 rows** in high-traffic user-facing APIs — use keyset or cursor instead.\n- **Returning the full dataset and paginating in the application layer** — this breaks at any meaningful table size.\n\n## FAQ\n\n**Can I jump to a specific page with cursor pagination?**\nNot directly — that is offset's strength. If you need \"go to page N,\" use offset. If you only need \"load more,\" use cursor.\n\n**How do I handle deleted rows with cursor pagination?**\nA deleted row does not affect cursor correctness because you are seeking after the cursor ID, not counting rows. This is a key advantage over offset.\n\n**What is the right response envelope for pagination?**\nReturn `data` (the items) and `pagination` (cursor or page info) at the top level. Be consistent across all list endpoints.\n\n**Should the cursor be human-readable?**\nNo — opaque base64 or JWT-like tokens prevent clients from assuming structure. Treat them as black boxes on both ends.\n\n## Where to go next\n\nSee [How to cache API responses in 2026](/blog/how-to-cache-api-responses-2026), [How to version an API in 2026](/blog/how-to-version-an-api-2026), and [Pagination explained in 2026](/blog/pagination-explained-2026).","hue":316,"metaTitle":"How to Paginate an API in 2026: Offset vs Cursor vs Keyset","metaDescription":"Compare offset, cursor, and keyset pagination in 2026. Code examples, SQL patterns, response formats, and when each approach breaks down.","keywords":["how to paginate an api 2026","api pagination best practices","cursor pagination vs offset","keyset pagination sql","rest api pagination 2026","page token pagination","infinite scroll api","sql pagination performance","limit offset problems","graphql pagination 2026"],"tldr":["**Cursor-based pagination beats offset** for large or frequently-updated datasets — it stays consistent across concurrent writes.","**Offset pagination is fine for small, stable tables** — simple to implement and good enough under ~10k rows.","**Always cap page size** — never let clients request unlimited rows; set a hard max (e.g. 100).","**Return a `nextCursor` or `nextPage` token** in your response, not just the raw data — clients should not have to compute page state.","**Skip:** using `OFFSET` on tables with millions of rows — at high offsets the DB scans and discards millions of rows per request."],"readTime":"4 min read","wordCount":928},"related":[{"slug":"pagination-explained-2026","title":"Pagination explained in 2026: offset, cursor, and keyset compared","excerpt":"Pagination shapes API performance and user experience more than most engineers expect. Here is the honest 2026 guide to offset, cursor, and keyset pagination.","category":"code","date":"March 2, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":311},{"slug":"pagination-cursor-vs-offset-2026","title":"Pagination in 2026: cursor vs offset — which one to use","excerpt":"Offset pagination breaks on large datasets and concurrent writes. Cursor pagination is the 2026 default for APIs and feeds — here is exactly how to implement it.","category":"code","date":"March 2, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":311},{"slug":"cursor-pagination-explained-2026","title":"Cursor Pagination: Why Offset Breaks at Scale","excerpt":"Offset pagination gets slower with every page and skips rows when data changes. Cursor pagination fixes both. Here is how to implement it correctly.","category":"code","date":"July 24, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":218}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"how-to-negotiate-credit-card-rates-2026","title":"How to negotiate credit card rates in 2026: scripts and what actually works","excerpt":"Credit card issuers will lower your APR if you ask the right way. Here is the 2026 guide with scripts, timing tips, and realistic expectations.","category":"finance","date":"April 16, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":308},"older":{"slug":"how-to-parse-json-2026","title":"How to parse JSON in 2026: Python, validation, and large-file tricks","excerpt":"JSON parsing in Python is a one-liner until the data is malformed, huge, or deeply nested. This 2026 guide covers the standard library, schema validation, streaming, and the fastest libraries for large payloads.","category":"code","date":"April 16, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":311}}