{"post":{"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","content":"<span class=\"dropcap\">P</span>agination is one of those problems that looks solved until the table has a million rows and the `OFFSET 990000 LIMIT 10` query starts taking two seconds. Every API that returns lists needs a pagination strategy, and the right choice depends on query patterns, product requirements, and expected data volume. Here is how each strategy actually works in 2026.\n\n## What changed in 2026\n\n- **Cursor-based pagination became the de-facto standard** for public APIs. GitHub, Stripe, and Notion all use opaque page tokens instead of raw offsets.\n- **Postgres `keyset` + `pgvector` patterns merged** — pagination over embedding search results now requires keyset semantics because cosine-distance offsets are meaningless.\n- **Edge databases** (Turso, Neon's branching, PlanetScale) pushed developers toward stable cursors to avoid inconsistent reads across distributed replicas.\n- **OpenAPI 3.1 standardized `Link` headers and `next_cursor` fields**, making cursor pagination easier to document and consume.\n\n## The three strategies\n\n### Offset pagination\n\n```sql\nSELECT id, title, created_at\nFROM posts\nORDER BY created_at DESC\nLIMIT 20 OFFSET 40;\n```\n\nThe database scans and discards the first 40 rows every time. Simple, but:\n\n- **O(n) scan cost** — `OFFSET 100000` reads 100 020 rows.\n- **Drift** — a new row inserted before page 2 is fetched pushes an item to the next page, causing duplicates or skipped rows.\n- Fine for small tables and admin UIs; bad for user-facing feeds.\n\n### Cursor pagination\n\n```http\nGET /posts?after=eyJpZCI6NDJ9&limit=20\n```\n\nThe server encodes the last-seen record into an opaque token (base64 JSON or ULID), then decodes it on the next request:\n\n```sql\nSELECT id, title, created_at\nFROM posts\nWHERE id < 42          -- decoded from cursor\nORDER BY id DESC\nLIMIT 20;\n```\n\nNo scan cost. Stable under concurrent inserts. Downside: **no random access** — you cannot jump to page 7 directly.\n\n### Keyset pagination\n\nA generalization of cursor pagination that supports multi-column sort orders:\n\n```sql\n-- Page after (created_at = '2026-05-20', id = 99)\nSELECT id, title, created_at\nFROM posts\nWHERE (created_at, id) < ('2026-05-20', 99)\nORDER BY created_at DESC, id DESC\nLIMIT 20;\n```\n\nWith a composite index on `(created_at, id)`, Postgres uses an index seek — constant cost regardless of offset depth. This is the right pattern for any high-volume, real-time feed.\n\n## Comparison table\n\n| Strategy | Seek cost | Stable under insert | Random access | Implementation complexity |\n| --- | --- | --- | --- | --- |\n| Offset | O(offset) | No | Yes | Low |\n| Cursor (ID-based) | O(1) | Yes | No | Medium |\n| Keyset (multi-column) | O(1) | Yes | No | Medium-High |\n| Search-after (Elasticsearch) | O(1) | Yes | No | Medium |\n\n## How to pick\n\n1. **Admin list UI with small table (<50k rows) and numbered pages?** Offset is fine. Ship fast.\n2. **Public REST API returning recent items?** Cursor pagination with an opaque token. Encode `{id, created_at}`, not just the ID, so you can add sort fields later without breaking existing cursors.\n3. **High-throughput feed or infinite scroll on a large table?** Keyset. Add the composite index before deploying.\n4. **Search results (Elasticsearch / Typesense)?** Use `search_after` — it is keyset semantics for inverted indexes.\n5. **Need total page count?** Only offset gives it cheaply. Cache the count or drop the requirement.\n\n## Encoding cursors safely\n\n```python\nimport base64, json\n\ndef encode_cursor(id: int, created_at: str) -> str:\n    payload = json.dumps({\"id\": id, \"ts\": created_at})\n    return base64.urlsafe_b64encode(payload.encode()).decode()\n\ndef decode_cursor(token: str) -> dict:\n    payload = base64.urlsafe_b64decode(token.encode())\n    return json.loads(payload)\n```\n\nTreat the cursor as **opaque** at the API surface — clients must not construct or parse them. This lets you change encoding without a versioning break.\n\n## Common mistakes\n\n**Sorting by non-unique column alone.** `ORDER BY created_at` with ties breaks keyset pagination. Always add a unique tiebreaker (`id`) to the sort and the cursor.\n\n**Exposing internal IDs in cursors.** Base64 is not encryption. If row IDs are sensitive, use ULIDs or HMACs.\n\n**Counting every page.** `SELECT COUNT(*)` on large tables is expensive. Return `has_more: true/false` instead of a total unless required.\n\n**Mixing ASC and DESC in keyset.** The row-value comparison `(col1, col2) < (v1, v2)` only works when all columns share the same sort direction. For mixed directions, build explicit `WHERE` clauses.\n\n**No index on the cursor columns.** A cursor query without an index on `(created_at, id)` is worse than offset — you lose the simplicity with none of the speed.\n\n## What to skip\n\n- **Offset on tables projected to grow past 100k rows** — the rewrite will come; plan for cursors from day one.\n- **Sending raw database IDs as cursors** when row-level privacy matters.\n- **Total count on every page** for public consumer APIs — clients rarely use it and the query cost is high.\n\n## FAQ\n\n**Can I support both offset and cursor at the same endpoint?**\nTechnically yes, but it creates confusing semantics. Better to pick one and document it. If you need numbered pages for a specific UI and a feed elsewhere, use separate endpoints.\n\n**How do I implement prev/next with keyset?**\nFor `prev`, reverse the sort direction in the query, then reverse the returned rows in code. Encode a `before` token alongside the `after` token.\n\n**What is a good cursor TTL?**\nMost APIs make cursors valid for 15–60 minutes. After that, re-fetch from the start. Stripe uses `starting_after` objects that never expire because they reference a stable record ID.\n\n**Does GraphQL have a standard pagination spec?**\nYes — the Relay Cursor Connections spec defines `edges`, `node`, `cursor`, `pageInfo.hasNextPage`, and `pageInfo.endCursor`. It is cursor-based by design.\n\n## Where to go next\n\n- [How to paginate an API in 2026](/blog/how-to-paginate-an-api-2026)\n- [How to optimize SQL queries in 2026](/blog/how-to-optimize-sql-queries-2026)\n- [REST APIs explained in 2026](/blog/what-is-rest-apis-2026)","hue":311,"metaTitle":"Pagination Explained 2026: Offset vs Cursor vs Keyset","metaDescription":"Offset, cursor, and keyset pagination compared in 2026. Real tradeoffs, SQL examples, and when to use each pattern for fast, stable APIs.","keywords":["pagination explained 2026","cursor pagination vs offset","keyset pagination","api pagination best practices 2026","offset limit sql performance","infinite scroll pagination","rest api pagination 2026","database pagination performance","page token pagination","how pagination works"],"tldr":["**Offset pagination is simple but breaks at scale** — `LIMIT/OFFSET` causes full index scans on large tables and drifts when rows are inserted mid-read.","**Cursor pagination solves drift** — encode the last-seen ID or timestamp, not a row position, so pages stay stable as the underlying data changes.","**Keyset pagination is the fastest** — range queries on indexed columns let Postgres and MySQL seek directly, avoiding O(n) scans entirely.","**Infinite scroll favors cursors; numbered pages favor offset** — match the pattern to your product requirement before picking.","**Skip:** implementing offset pagination on tables that will exceed 100k rows; you will rewrite it."],"readTime":"4 min read","wordCount":953},"related":[{"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":"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":"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},"older":{"slug":"qled-vs-mini-led-2026","title":"QLED vs Mini-LED in 2026: which display tech should you buy?","excerpt":"QLED and Mini-LED have both matured, but they serve different priorities. Here is the honest 2026 breakdown of contrast, brightness, and value.","category":"tech","date":"March 2, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":244}}