Area 08 // BackendLesson 138 / 14011 min read
Pagination
Pagination returns long lists (conversations, documents, search results, logs) a page at a time, either with page numbers and offsets, which are simple but slow and unstable on large or changing data, or with cursors that say "continue after this item", which are fast and consistent.
Think of it like this
Analogy
Reading a long book: offset pagination is "start reading at word 20,000", where you count from the beginning every time, and if someone inserts a paragraph earlier, you reread a few lines. Cursor pagination is a bookmark: you continue exactly where you left off, however long the book or whatever was added before.
The problem it solves
A user has 3,000 conversations, a tenant has 200,000 documents, a logs view has millions of rows. Returning everything at once is slow, wastes memory and bandwidth, and can crash the browser. Splitting into pages with page numbers seems simple, but on large, constantly growing tables it gets slow and shows duplicates or skips items, which is exactly what chat histories and activity feeds are like.
How it works, step by step
- Always bound list endpoints: a default page size (e.g. 20) and a maximum (e.g. 100), validated like any input (validation).
- Choose a stable sort order: a unique, indexed key such as
id, or(created_at, id)with a tie-breaker (indexing). - Offset pagination:
?page=3&size=20→LIMIT 20 OFFSET 40.- Pros: simple, supports jumping to page N and showing total pages.
- Cons: the database reads and skips all earlier rows (slow for deep pages); inserts and deletes shift items between pages (duplicates or gaps; see the code).
- Cursor (keyset) pagination:
?cursor=abc→WHERE id < :last_id ORDER BY id DESC LIMIT 20.- Return
next_cursor(the last item's key, encoded); the client sends it back for the next page; no cursor means the end. - Pros: fast at any depth (uses the index), stable under inserts.
- Cons: no jumping to arbitrary pages; total counts need a separate query.
- Return
- Make cursors opaque (base64 of the sort values) so you can change the implementation later.
- Response shape:
{"items": [...], "next_cursor": "..."}, sometimes withhas_more. - For AI apps: conversation lists, message history (infinite scroll upwards), document libraries, eval runs and traces all suit cursors; vector search results are a ranked top-k, usually not paginated deeply.
See it in code
- Needs
- Nothing: plain Python 3 (sqlite3 is built in).
Offset vs cursor pagination over 25 conversations, with two new conversations arriving between page 1 and page 2.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE conversations (id INTEGER PRIMARY KEY, title TEXT)")
db.executemany("INSERT INTO conversations (title) VALUES (?)", [(f"chat {i}",) for i in range(1, 26)])
# OFFSET pagination: easy, but slow on deep pages and unstable when rows are added.
def page_offset(page: int, size: int = 10):
return [r[0] for r in db.execute(
"SELECT id FROM conversations ORDER BY id DESC LIMIT ? OFFSET ?", (size, (page - 1) * size))]
# CURSOR (keyset) pagination: "give me 10 items older than the last one I saw".
def page_cursor(after_id: int | None, size: int = 10):
if after_id is None:
rows = db.execute("SELECT id FROM conversations ORDER BY id DESC LIMIT ?", (size,))
else:
rows = db.execute("SELECT id FROM conversations WHERE id < ? ORDER BY id DESC LIMIT ?", (after_id, size))
ids = [r[0] for r in rows]
return {"items": ids, "next_cursor": ids[-1] if len(ids) == size else None}
p1_offset = page_offset(1)
p1_cursor = page_cursor(None)
print("page 1 :", p1_offset)
db.executemany("INSERT INTO conversations (title) VALUES (?)", [("new chat",), ("another new chat",)]) # new rows arrive
p2_offset = page_offset(2)
p2_cursor = page_cursor(p1_cursor["next_cursor"])
print("page 2 offset :", p2_offset, "<- repeats", sorted(set(p1_offset) & set(p2_offset), reverse=True))
print("page 2 cursor :", p2_cursor)
print("page 3 cursor :", page_cursor(p2_cursor["next_cursor"]))page 1 : [25, 24, 23, 22, 21, 20, 19, 18, 17, 16]
page 2 offset : [17, 16, 15, 14, 13, 12, 11, 10, 9, 8] <- repeats [17, 16]
page 2 cursor : {'items': [15, 14, 13, 12, 11, 10, 9, 8, 7, 6], 'next_cursor': 6}
page 3 cursor : {'items': [5, 4, 3, 2, 1], 'next_cursor': None}With offsets, the two new conversations pushed 17 and 16 onto page 2, so the user sees them twice. The cursor version continued after 16 exactly. In a real API the cursor would be encoded, e.g. base64 of {"id": 16}.
A real example
Example
Infinite scroll in a chat history
A chat app loaded message history with ?page=N. Busy group conversations showed duplicated and missing messages while scrolling up, because new messages kept shifting offsets, and loading old history in long conversations took seconds because of deep offsets. Switching to cursor pagination on (conversation_id, created_at, id) with a matching composite index made every page load in a few milliseconds regardless of depth, and the duplicates disappeared.
The easy way
Start here
For any list that grows (messages, conversations, logs), use cursor pagination on the ID or creation time with an index; return items and next_cursor; cap the page size. Use offsets only for small admin tables where page numbers help.
When you grow: the production setup
Teams standardise a pagination contract across the API (opaque cursors, limit caps, consistent response envelope), use keyset pagination with composite indexes and tie-breakers, provide filters with the same cursor semantics, compute totals separately or approximately, and test pagination under concurrent inserts.
Trade-offs
What you gain
- Fast, bounded responses for any list size.
- Cursor pagination stays correct while data changes.
- Lower memory and bandwidth use.
What it costs you
- Cursors don't support "jump to page 37".
- Totals need extra queries.
- Sort order changes need new cursor logic.
When not to use it
- Fixed tiny lists (a dozen settings) don't need pagination.
Common mistakes
- Unbounded list endpoints.
- Offset pagination on huge, growing tables.
- Sorting by a non-unique column without a tie-breaker.
- Exposing raw database IDs in cursors and then being unable to change them.
- No index on the sort columns.
Interview questions
Try answering out loud first, then open the model answer.
Offset vs cursor pagination: trade-offs?
Model answer
Offset pagination is simple and supports random page access and totals, but gets slower with depth (rows are read and skipped) and is unstable under inserts and deletes. Cursor pagination uses the last item's sort key to continue, so it's fast at any depth via an index and stable under changes, but doesn't support jumping to arbitrary pages and needs a unique sort key.
How would you paginate a user's chat history?
Model answer
Keyset pagination on (conversation_id, created_at, id) with a matching composite index, fetching messages older than the cursor in descending order with a capped limit, returning an opaque next_cursor; the client loads more as the user scrolls up. New messages arrive through streaming or a separate query for newer items.
Quiz
Pick an answer to see whether it’s right and why.
0 of 5 answered
Key terms
- Pagination
- Returning a long list one page at a time instead of all at once.
- Offset pagination
- Paging by skipping a number of rows (LIMIT and OFFSET). Simple, but slow for deep pages and unstable when rows are added.
- Cursor pagination
- Paging by continuing after the last item seen, using a cursor the server returns. Fast at any depth and stable while data changes.
- Keyset pagination
- The database technique behind cursor pagination, filtering with WHERE on the sort key (for example id less than the last id) instead of OFFSET.
- Page size
- How many items one page returns. APIs set a default and a maximum.
Before and after
Progress is saved in this browser only.