Pagination (OFFSET vs keyset)
What Pagination Is
Pagination means returning a long list in small pages instead of all at once. The "My Orders" page in our app shows 20 orders, then loads 20 more as the user scrolls.
There are two common ways to ask for "the next 20":
| OFFSET pagination | Keyset (cursor) pagination | |
|---|---|---|
| Asks | "Skip 40 rows, give me the next 20" | "Give me 20 rows after the last one I saw" |
| Remembers | A page number | The last row's sort values (a cursor) |
| Speed on deep pages | Gets slower | Stays fast |
| Jump to page 50 | Yes | No |
Think of a long book. OFFSET is "turn to page 500" by counting every page from the start. Keyset is a bookmark: you open right where you stopped.
OFFSET Pagination
OFFSET skips a number of rows, and LIMIT caps how many come back:
-- page 3 (20 per page): skip 40, take 20SELECT id, amount, created_atFROM ordersWHERE user_id = 42ORDER BY created_at DESCLIMIT 20 OFFSET 40;
It's simple and supports page numbers. But it has two problems.
Problem 1: deep pages get slow. The database can't jump to row 100,000. It reads and throws away every row before it:
1OFFSET 0 LIMIT 20 → read 20 rows → fast2OFFSET 1000 LIMIT 20 → read 1,020 rows → fine3OFFSET 100000 LIMIT 20 → read 100,020 rows,4 throw away 100,000 → slowThe work grows with the page number, even with an index.
Problem 2: rows shift while scrolling. If a new order arrives between page loads, everything moves down by one:
1Load page 1: orders A B C ... T (20 newest)2New order Z arrives at the top3Load page 2 (OFFSET 20): starts at T again4→ order T shows twice5(If an order is deleted instead, one gets skipped)Keyset (Cursor) Pagination
Keyset pagination remembers the last row of the current page and asks for rows after it. With an index on the sort columns, the database jumps straight there, like the B-tree lookup from the indexing pages.
The sort column must be unique, or rows with the same value can be skipped. Two orders can share the same created_at, so we add id as a tie-breaker:
1Page 1: 20 newest orders → last one is (created_at = 2026-10-20 19:05, id = 881)2 3Page 2: WHERE (created_at, id) < (2026-10-20 19:05, 881)4 ORDER BY created_at DESC, id DESC5 LIMIT 206→ jumps to that spot in the index, reads 20 rows-- index matching the sort orderCREATE INDEX idx_orders_user_created_idON orders (user_id, created_at DESC, id DESC);-- first pageSELECT id, amount, created_at FROM ordersWHERE user_id = 42ORDER BY created_at DESC, id DESCLIMIT 20;-- next page: after the last row seenSELECT id, amount, created_at FROM ordersWHERE user_id = 42AND (created_at, id) < ('2026-10-20 19:05:00', 881)ORDER BY created_at DESC, id DESCLIMIT 20;
The values are sent to the client as an opaque cursor string (here, base64-encoded JSON). The client sends it back to get the next page, without needing to know what's inside.
1GET /orders → { orders: [...20], nextCursor: "eyJjcmVh..." }2GET /orders?cursor=eyJjcmVh... → { orders: [...20], nextCursor: "eyJ0aW1l..." }New orders arriving at the top don't shift anything, because each page starts after a fixed row, not after a count.
Trade-offs
| OFFSET | Keyset | |
|---|---|---|
| Page 1 speed | Fast | Fast |
| Page 5,000 speed | Slow, reads all earlier rows | Fast, same as page 1 |
| New or deleted rows during scrolling | Duplicates or skips | Stable |
| Jump to any page number | Yes | No, only next (and previous with reversed query) |
| Total page count | Easy with COUNT(*) (costly on big tables) | Not natural |
| Sort by any column | Easy | Needs a unique tie-breaker and a matching index |
| Choose for | Small tables, admin screens with page numbers | Feeds, infinite scroll, APIs, large tables |
Interview Angle
How to Explain it in an Interview
Common mistakes:
- Sorting keyset pages by a non-unique column only, like
created_at, which skips rows that share a value. - Forgetting the index that matches the keyset sort order.
- Running
COUNT(*)on a huge table on every page load just to show total pages.
Summary
1OFFSET → skip N, take 20 → simple, page numbers2 → slow on deep pages, duplicates/skips when data changes3Keyset → "after the last row I saw" → same speed on every page4 → needs unique tie-breaker (created_at, id) + matching index5 → no jumping to page N6Cursor → last row's values, encoded and sent to the client7Choose → OFFSET for small/admin lists, keyset for feeds, scroll, APIsFast pages assume the table's structure stays put. Next, migrations cover how to change a schema safely while the app is running.