"Offset pagination is the natural first pattern. It works fine until your dataset grows past a few thousand rows, or anyone writes to the table between page requests. Then it breaks in two specific ways that cursor pagination doesn't."
Why You Have to Paginate
Any API that returns a list eventually returns too much of it. GET /users with 50,000 users is a hostile response — the client downloads megabytes, the server scans the whole table, the UI freezes rendering. Pagination is the protocol for "give me some, then I'll ask for more."
Three patterns dominate, with very different tradeoffs.
Pattern 1: Offset/Limit (or Page/Page-Size)
The natural first design: GET /users?offset=100&limit=20 means "skip the first 100, give me 20." Pure SQL: SELECT ... LIMIT 20 OFFSET 100.
Pros: trivial to implement, intuitive for UI "jump to page 5" features, plays well with total-count display.
Cons (both serious):
- Unstable under writes. If a new row is inserted at position 50 between page 1 (offset=0) and page 2 (offset=20), page 2 starts at what USED to be row 20 but is now row 21 — you skip row 21's old content (now at index 22), or you see row 20's old content (now at index 21) twice. For active datasets this happens constantly.
- O(N) at large offsets. SELECT ... LIMIT 20 OFFSET 1000000 requires the DB to scan and skip 1,000,000 rows. There's no index trick; offset is computational. Past a few thousand offset, performance degrades sharply.
Pattern 2: Cursor Pagination
The client passes a cursor identifying "the last item I saw," and the server returns the next N items strictly after that cursor. GET /users?cursor=usr_8x3kPq&limit=20 means "give me the 20 users sorted right after this one."
Pros (both fix the offset problems):
- Stable under writes. The cursor encodes a specific position in the sort order, not an index. Inserts and deletes don't shift the cursor's meaning.
- O(log N) regardless of position. The server uses an indexed predicate (
WHERE id > 'usr_8x3kPq' ORDER BY id LIMIT 20), which is an index range scan. Same speed at cursor=position-1 as at cursor=position-1,000,000.
Cons: can't jump to an arbitrary page (no "page 5 of 20" UI); cursor must be opaque (clients shouldn't construct them); needs a stable sort order (usually by created_at + id tiebreaker).
Pattern 3: Page Tokens (Google / AWS Style)
Like cursors but explicitly opaque — the server returns a nextPageToken, and the client passes it back on the next request. Server can encode anything it wants (sort position, filter snapshot, anti-tampering signature). Google Cloud APIs and AWS use this.
Pros: same as cursor + server can change the encoding without breaking clients.
Cons: same as cursor + opacity makes debugging harder (you can't read the token to see where you are).
Returning Pagination Metadata
Three places the next-page info can live:
- Response body.
{"items": [...], "next_cursor": "abc", "has_more": true}. Most common; explicit; clients always see it. - Link header (RFC 8288).
Link: </users?cursor=abc>; rel="next". The HATEOAS-flavored approach. GitHub does this. - Both. Belt and suspenders — body for SDK convenience, Link header for tooling that parses standard headers.
Total count is a separate question. If you need it ("showing 1-20 of 4,500"), return it as {"total": 4500} in the body or X-Total-Count: 4500. But computing total count on every request is expensive on large tables — many APIs return only "more available" booleans and reserve totals for explicit count endpoints.
cwkPippa's Pagination Reality
?offset=0&limit=50) because the dataset is small (Dad has maybe 500 conversations total across all four brains). When that count grows past a few thousand, cursor pagination becomes attractive — particularly for the council list, which accumulates round-by-round. Switching is a known future task; the current offset pagination ships fine until then. This is the right place to defer: low cost now, clear migration path when the cost flips.