Never return an unbounded list. The way you paginate seems trivial and is the difference between an API that scales and one that falls over on page 10,000.
Prerequisites: API Design Principles, Indexing Time to read: ~18 minutes
GET /users → returns all 50 million users
🚨 This is a design error, not a missing feature. It exhausts your database (a full scan), your server’s memory (serializing 50 million records), the network (gigabytes), and the client. And retrofitting pagination onto an endpoint that clients already use is a breaking change.
Every collection endpoint needs pagination from day one, with a default limit. That’s non-negotiable.
The interesting part is how — and offset pagination, the obvious choice, is the wrong one at scale.
GET /users?limit=20&offset=40 → SELECT * FROM users LIMIT 20 OFFSET 40
✅ Simple, intuitive, lets you jump to any page (page=500), and shows total counts.
🚨 Two serious flaws:
1. It gets slower as you go deeper — O(offset). OFFSET 1000000 makes the database read and
discard one million rows to return 20.
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000000;
-- The database fetches 1,000,020 rows and throws away 1,000,000. Slow, and it worsens per page.
At page 50,000, this query is unusable. Deep offset pagination is a real production problem — a scraper or an infinite-scroll UI walks to a deep page and hammers the database.
2. It’s inconsistent under writes. If a row is inserted or deleted between page requests, items shift:
Page 1: rows 1–20
(someone inserts a new row at the top)
Page 2 (offset 20): row 20 appears again — the user sees a duplicate
🚨 In a fast-changing list (a feed, a search result), offset pagination shows duplicates and skips. The user scrolling a feed sees the same post twice, or misses one entirely.
Verdict: fine for small, stable, admin-facing datasets where page-jumping matters. Wrong for large or changing data.
Instead of “skip N rows,” say “give me rows after this specific point.”
GET /users?limit=20 → first page
→ returns items + a cursor pointing at the last item
GET /users?limit=20&after=eyJpZCI6MTIzfQ → next page, after that cursor
The cursor encodes the position (usually the sort key of the last item):
-- No OFFSET. Uses the index directly. O(1) regardless of depth.
SELECT * FROM users WHERE id > :last_seen_id ORDER BY id LIMIT 20;
🚨 Why this is better:
WHERE id > 1000000 uses the index to jump straight to the
position — no scanning and discarding. Page 50,000 is as fast as page 1.⚖️ What you give up:
🎙️ The answer that scores well: “I’d use cursor pagination. Offset gets slower the deeper you go because the database scans and discards rows, and it shows duplicates when the list changes under you. Cursor pagination is constant-time at any depth and stable under writes — the trade-off is you can’t jump to an arbitrary page, which infinite scroll doesn’t need anyway.”
🚨 A subtle cursor-pagination bug worth knowing, because it’s a common follow-up.
If you paginate by a non-unique field (created_at), rows with the same value straddle a page
boundary and get skipped or duplicated:
-- ❌ Two users created at the same instant: one may be lost across the boundary
WHERE created_at > :last_created_at ORDER BY created_at LIMIT 20;
Fix: paginate by a composite of the sort field plus a unique tiebreaker (the ID):
-- ✅ Always breaks ties consistently
WHERE (created_at, id) > (:last_created_at, :last_id)
ORDER BY created_at, id LIMIT 20;
The cursor encodes both values. This needs a composite index on (created_at, id).
→ Indexing
The cursor should be opaque — clients treat it as a token, not something to parse or construct.
after=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0yMiIsImlkIjoxMjN9
= base64({"created_at": "2026-07-22", "id": 123})
🚨 Why opaque matters: if clients parse the cursor, its structure becomes a contract you can never change — you couldn’t switch sort fields or add a tiebreaker without breaking them. Base64-encoding (and ideally signing, to prevent tampering) keeps it an implementation detail. Encoding the sort parameters in the cursor also means the client can’t accidentally change the sort mid-pagination.
GET /users?page=3&per_page=20
Just offset pagination with friendlier parameters (offset = (page-1) × per_page). Same flaws. Use
it only for the same cases offset is acceptable: small, stable, admin-facing data where users expect
page numbers and totals.
| Offset / page-number | Cursor / keyset | |
|---|---|---|
| Jump to page N | ✅ | ❌ Next/previous only |
| Total count | ✅ Easy | ❌ Hard/expensive |
| Deep pages | ❌ O(offset), gets slow | ✅ O(1), constant |
| Stable under writes | ❌ Duplicates/skips | ✅ Consistent |
| Complexity | Simple | Moderate |
| Best for | Small, stable, admin UIs | Large data, feeds, infinite scroll, public APIs |
Filtering via query parameters:
GET /orders?status=shipped&min_total=1000&created_after=2026-01-01
🚨 Every filterable field must be indexed, or filtering triggers a full scan — the same problem pagination was solving. Don’t advertise a filter you can’t serve efficiently. → Indexing
Complex filters — for anything beyond simple equality, either a structured query language (RSQL, or a JSON filter object) or a dedicated search engine. Don’t build a half-baked query language into your REST params.
Sorting:
GET /orders?sort=-createdAt,total → createdAt descending, then total ascending
🚨 Sortable fields must be indexed, and 🚨 sorting interacts with cursor pagination — the cursor must encode the sort. If a client can sort by any field, you need cursor logic (and an index) per sort option, which is why public APIs usually restrict sorting to a few indexed fields.
The total count cost: returning a total ("totalCount": 48213) requires a COUNT(*) with the
same filters, which can be as expensive as the query itself on a large table. Options: omit it (cursor
style), cache/approximate it, or compute it only when explicitly requested (?include_total=true).
{
"data": [ /* the items */ ],
"pagination": {
"nextCursor": "eyJpZCI6MTQzfQ",
"hasMore": true,
"limit": 20
}
}
Rules:
limit=1000000 must be capped, e.g. at 100). 🚨 Otherwise a
client asking for everything is back to the unbounded-list problem.hasMore is cleaner than a total for cursor pagination — the client knows whether to keep
going without an expensive count.| Choice | Gain | Cost |
|---|---|---|
| Offset pagination | Page-jumping, easy totals, simple | Slow at depth; duplicates/skips under writes |
| Cursor pagination | Constant-time, stable under writes | No page-jumping; hard totals; sort field must be indexed+unique |
| Opaque cursors | Free to change internals | Clients can’t construct or reason about them |
| Returning totals | Useful UI (“page 3 of 500”) | Expensive COUNT(*) on large tables |
| Rich filtering/sorting | Powerful | Every field needs an index; complexity to secure |
starting_after / ending_before) with opaque cursors — a
clean reference implementation of the pattern.?page=100000, the database chews through millions of rows per request, and the endpoint takes down
the database. Cursor pagination or a hard depth cap prevents it.COUNT(*) cost.hasMore rather than a total — a COUNT(*) on a large filtered
table can cost as much as the query itself.”1. Feel the deep-offset problem. Load 10 million rows. Time LIMIT 20 OFFSET 0, then
OFFSET 5000000, then OFFSET 9000000. Watch the query time climb linearly with the offset. Then
run the equivalent cursor query (WHERE id > :x LIMIT 20) at each depth and watch it stay flat. This
single comparison is the whole argument.
2. Cause duplicates. Paginate a table with offset while inserting new rows at the top between page requests. Observe items appearing on two pages. Then switch to cursor pagination and confirm the duplicates disappear.
3. Hit the tie-break bug. Paginate by a non-unique created_at where several rows share a
timestamp across a page boundary. Confirm one gets skipped. Then paginate by (created_at, id) and
confirm it doesn’t.
4. Build opaque cursors. Implement base64-encoded cursors carrying (sort_value, id). Then try to
change your sort field and confirm existing cursors still decode sensibly (or are cleanly rejected) —
that’s the flexibility opaque cursors buy.