{"post":{"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","content":"<span class=\"dropcap\">P</span>agination looks simple — just add `LIMIT 20 OFFSET 0` and call it done. But that shortcut quietly breaks as your dataset grows and your users scroll deeper. By page 500, offset pagination is scanning 10,000 rows it will throw away, and any new insert causes rows to shift, creating duplicates or gaps in the feed. Cursor pagination fixes both problems. Here is the 2026 guide.\n\n## What changed in 2026\n\n- **Most API design guides** (Stripe, GitHub, Shopify) now default to cursor pagination. Offset is considered a legacy pattern.\n- **React Query, SWR, and TanStack Query** all have first-class support for cursor-based infinite scroll, making client-side implementation straightforward.\n- **GraphQL Relay spec** (connections + edges + pageInfo) standardised cursor pagination across the GraphQL ecosystem, and more REST APIs adopted compatible patterns.\n- **Databases at scale** — even Postgres on managed services like Neon or Supabase — see the cost of deep-offset queries more visibly on shared infrastructure.\n\n## How each approach works\n\n**Offset pagination** tells the database: skip N rows, then return M.\n\n```sql\n-- Page 1\nSELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 0;\n-- Page 500 (scans 10,000 rows, discards them, returns 20)\nSELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 9980;\n```\n\n**Cursor pagination** (keyset) tells the database: find the row after this specific value and return M.\n\n```sql\n-- Page 1\nSELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20;\n-- Page 2 (uses index seek, scans only 20 rows)\nSELECT * FROM posts\nWHERE (created_at, id) < ('2026-05-01 10:00:00', 9876)\nORDER BY created_at DESC, id DESC\nLIMIT 20;\n```\n\nThe `WHERE` clause in cursor pagination is satisfied by an index seek — O(log n) regardless of page depth.\n\n## Performance comparison\n\n| Approach | Page 1 rows scanned | Page 500 rows scanned | Works with live inserts |\n| --- | --- | --- | --- |\n| OFFSET | 20 | 10,000 | No (rows shift) |\n| Cursor (keyset) | 20 | 20 | Yes |\n| Cursor (opaque token) | 20 | 20 | Yes |\n\n## Implementing cursor pagination in a REST API\n\n```ts\n// GET /posts?cursor=<token>&limit=20\n\nasync function getPosts(cursor?: string, limit = 20) {\n  let where = {};\n  if (cursor) {\n    const { createdAt, id } = decodeCursor(cursor); // base64 JSON\n    where = { OR: [\n      { createdAt: { lt: createdAt } },\n      { createdAt: createdAt, id: { lt: id } },\n    ]};\n  }\n  const posts = await db.post.findMany({\n    where,\n    orderBy: [{ createdAt: \"desc\" }, { id: \"desc\" }],\n    take: limit + 1, // fetch one extra to know if next page exists\n  });\n  const hasNextPage = posts.length > limit;\n  if (hasNextPage) posts.pop();\n  const nextCursor = hasNextPage\n    ? encodeCursor({ createdAt: posts.at(-1)!.createdAt, id: posts.at(-1)!.id })\n    : null;\n  return { posts, nextCursor };\n}\n```\n\nEncode the cursor as opaque base64 so clients cannot construct arbitrary values.\n\n## The required index\n\n```sql\n-- Composite index matching the ORDER BY + WHERE columns\nCREATE INDEX idx_posts_created_id ON posts (created_at DESC, id DESC);\n```\n\nWithout this index, cursor pagination degrades to a sequential scan. The index must exactly match the sort order used in the query.\n\n## How to pick\n\n1. **Small dataset (<5k rows), admin panel, rarely updated** → Offset is fine. Simpler to implement, easy to jump to page N.\n2. **Feed, timeline, or large dataset** → Cursor / keyset. Scales, no duplicate rows on inserts.\n3. **User wants \"jump to page 47\"** → Offset only (cursors cannot random-access). Consider whether this is a real user need.\n4. **Infinite scroll / \"load more\"** → Cursor. No gaps, no duplicates.\n5. **GraphQL API** → Relay connection spec with cursors.\n\n## Common mistakes\n\n**Cursor on a non-unique column alone.** If `created_at` has ties, your cursor can produce duplicate or skipped rows. Always include the primary key as a tiebreaker: `ORDER BY created_at DESC, id DESC`.\n\n**Exposing raw cursor values.** If your cursor is `id=1234`, clients can enumerate records. Use opaque base64-encoded tokens.\n\n**Not handling the last page.** When `nextCursor` is null, stop fetching. Many clients loop until they get an empty page — document this explicitly in your API.\n\n**Changing sort order between pages.** The cursor is only valid for the same sort order used to generate it. If the client sorts differently, they need a new first page.\n\n**Using offset for search results.** Search indexes (Elasticsearch, Typesense) support search_after for cursor pagination. Deep-offset search is even slower than deep-offset SQL.\n\n## What to skip\n\n- **Page numbers in user-facing URLs for feeds.** Use cursor tokens; page numbers become stale the moment data changes.\n- **Bidirectional cursors** (previous + next) unless you genuinely need them. They double implementation complexity; most feeds are append-only and only need a \"next\" cursor.\n- **Storing cursors in the database.** The cursor encodes the position in the data, not a server-side state. Keep it stateless.\n\n## FAQ\n\n**Can I mix cursor and offset pagination?**\nYes but carefully. Offset for low-page-count admin tables, cursor for feeds and APIs. Do not use both on the same endpoint.\n\n**How do I handle deleted rows with cursor pagination?**\nDeletions do not affect cursor pagination — the WHERE clause seeks past the deleted position naturally. Offset pagination has the worse problem: it shifts all subsequent pages.\n\n**What is an opaque cursor?**\nA base64-encoded representation of the sort key values (e.g., `{ \"createdAt\": \"2026-05-01T10:00:00Z\", \"id\": 9876 }`). It is opaque to the client but parseable by the server.\n\n**Does this work with ORMs?**\nYes. Prisma supports cursor pagination natively. Drizzle and Sequelize require manual WHERE clauses but the SQL is straightforward.\n\n## Where to go next\n\n- [SQL joins explained in 2026](/blog/sql-joins-explained-2026)\n- [Indexes explained in 2026](/blog/indexes-explained-2026)\n- [How to build a REST API in Node in 2026](/blog/how-to-build-a-rest-api-in-node-2026)","hue":311,"metaTitle":"Cursor vs Offset Pagination 2026: The Right Choice","metaDescription":"2026 guide to cursor vs offset pagination. Why offset breaks at scale, how keyset/cursor pagination works, and full implementation examples in SQL and Node.","keywords":["cursor vs offset pagination 2026","keyset pagination sql","cursor pagination api 2026","offset pagination performance","pagination large datasets 2026","rest api pagination best practices","sql limit offset slow","infinite scroll pagination 2026","page token implementation","postgres keyset pagination"],"tldr":["**Offset pagination is simple but broken at scale** — `OFFSET 10000` still scans 10,000 rows on most databases.","**Cursor pagination is O(log n)** — it uses an indexed seek and scales to billions of rows.","**Cursors prevent duplicate/skipped rows** on live data; offset does not.","**Keyset pagination** (a specific cursor form) is the right default for REST APIs and feeds.","**Skip:** offset-based pagination for any dataset larger than ~10k rows or any feed with real-time inserts."],"readTime":"4 min read","wordCount":966},"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":"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","readTime":"4 min read","hue":316},{"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":"orms-explained-2026","title":"ORMs explained in 2026: what they are, when to use them, and when to go raw SQL","excerpt":"Object-relational mappers promise to simplify database access, but they also introduce hidden complexity. Here is the honest 2026 guide to using ORMs well.","category":"code","date":"March 2, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":319},"older":{"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}}