How it works
The explanation, with examples you can run.
OFFSET, measured
On 500,000 orders with an index on (created_at, id):
EXPLAIN ANALYZESELECT id, created_at FROM ordersORDER BY created_at, idLIMIT 20 OFFSET 400000;Limit (cost=12160.42..12161.03 rows=20 width=16) (actual time=20.032..20.033 rows=20 loops=1) -> Index Only Scan using orders_created_id_idx on orders (cost=0.42..15200.42 rows=500000 width=16) (actual time=0.034..14.033 rows=400020 loops=1) Heap Fetches: 0Execution Time: 20.039 msThe index avoids a sort, but it still reads 400,020 entries to return 20. Without the index it was worse: a full sort of the table that spilled to disk (61 ms here). Page 20,000 of a busy table is reached by crawlers and scripts more often than by people, and each such request does this much work.
Keyset: continue after the last row
Remember the sort key of the last row on the page and ask for rows after it. A row comparison does this neatly, and matches the index column by column:
EXPLAIN ANALYZESELECT id, created_at FROM ordersWHERE (created_at, id) > ('2025-10-10 18:40:00+01', 400000)ORDER BY created_at, idLIMIT 20;Limit (cost=0.42..1.08 rows=20 width=16) (actual time=0.014..0.015 rows=20 loops=1) -> Index Only Scan using orders_created_id_idx on orders (cost=0.42..3049.08 rows=92609 width=16) (actual time=0.014..0.014 rows=20 loops=1) Index Cond: (ROW(created_at, id) > ROW('2025-10-10 18:40:00+01'::timestamp with time zone, 400000)) Heap Fetches: 0Execution Time: 0.026 msThe Index Cond lets the scan start at the boundary and read exactly 20 entries, however deep the page. Three rules make it correct:
- The
ORDER BYmust be unique; add the primary key as a last column. - The index must match the
ORDER BY(same columns, same order; for newest first, compare with<and orderDESCon both). - The cursor you give the client is the last row's sort values, usually encoded as an opaque string.
Seeing both behaviours
This plain-JS simulation pages through 100 rows sorted by (createdAt, id), inserts a row at the front between pages, and counts the rows each method has to examine.
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
The cost of keyset pagination is that you cannot jump to an arbitrary page number. For infinite scroll, "load more" and API cursors that is no loss; for a numbered admin table with a few pages, OFFSET is fine.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.