OFFSET 1000000: Why Your Pagination Falls Over at Page 10,000

Page 2 of the order list loads instantly. Page 5,000 takes 8 seconds. Same query, same index — OFFSET makes Postgres scan and discard everything before the page you asked for. Cursor-based pagination fixes it for good.

Saksham Sharma @bytefisk

RedBite’s admin order list paginates with the obvious SQL:

SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 50 OFFSET 250000;

Page 2 (OFFSET 50) returns in a few milliseconds. By the time an admin clicks through to page 5,000 (OFFSET 250000) during a bulk audit, the same query takes 8 seconds — on a table that isn’t even RedBite’s largest. Nothing about the table changed between those two requests. OFFSET itself is the entire cost.

Why OFFSET gets slower as the page number grows

OFFSET 250000 doesn’t mean “jump straight to row 250,000.” Postgres has no way to know which physical row is the 250,000th in sort order without first counting through everything before it. The actual execution: scan the index in created_at DESC order, walk past the first 250,000 matching rows one at a time, discarding each one, and only then start returning the 50 rows the query actually asked for.

EXPLAIN ANALYZE SELECT * FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 250000;

Limit  (cost=12863.42..12865.99 rows=50)
  ->  Index Scan Backward using orders_created_at_idx on orders
        (actual time=0.05..187.32 rows=250050 loops=1)

rows=250050 in the actual line is the tell: Postgres read and discarded 250,000 rows to get to the 50 it returns. OFFSET 50 on page 2 discards 50 rows — trivial. OFFSET 250000 on page 5,000 discards 250,000 — and that discard cost grows linearly with the page number, forever, with no index able to shortcut it, because the index only tells Postgres the order of rows, not how to skip directly to an arbitrary position in that order.

slowfastOFFSET value, growing left to rightquery time grows linearly with OFFSET

Fix: cursor-based pagination — remember where you left off

Instead of asking Postgres to count through N rows every single page, remember the last row you actually saw, and ask for “everything after that specific row” instead of “everything after position N.” Postgres’s index can jump straight to a specific value efficiently — that’s what indexes are for — it just can’t jump to a specific position without counting there.

-- first page: no cursor yet
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 50;

-- next page: cursor = (created_at, id) of the last row from the previous page
SELECT * FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 50;
EXPLAIN ANALYZE SELECT * FROM orders
WHERE (created_at, id) < ('2026-08-14 09:00:00', 48213)
ORDER BY created_at DESC, id DESC LIMIT 50;

Limit  (cost=0.43..4.21 rows=50)
  ->  Index Scan Backward using orders_created_at_id_idx on orders
        (actual time=0.02..0.09 rows=50 loops=1)

rows=50 in the actual line, not 250050 — Postgres’s index seeks directly to the cursor value and reads exactly the 50 rows needed, with no discard pass at all. This query costs the same whether it’s “page 2” or “page 5,000,” because there is no page number in it — only “give me the next 50 rows after this specific point,” which is a seek, not a scan.

Why the tiebreaker column (id) matters: created_at alone isn’t guaranteed unique — two orders can share a timestamp down to the stored precision. Without id as a tiebreaker in both the ORDER BY and the WHERE comparison, rows with a duplicate created_at value could be skipped or repeated across page boundaries. The composite (created_at, id) comparison guarantees a strict, total order with no ties, so paging forward never drops or duplicates a row.

OFFSET paginationcursor paginationscan + discard 250,000 rowsreturn 50 rowsindex seek to cursor valuereturn 50 rowssame cost on page 2 and page 5,000

What cursor pagination gives up, on purpose

Cursor pagination isn’t a strict upgrade — it trades away two things OFFSET gives you for free, and both matter for deciding where to use it:

What RedBite shipped

Kept OFFSET for the first couple of pages’ UI (page 1, 2, 3 links, genuinely useful for an admin jumping around near the top) and switched to cursor-based “load more” for anything past that — which, in practice, is where every slow query in the logs was actually coming from. The 8-second page 5,000 query doesn’t exist anymore, because nothing asks for “page 5,000” anymore — it asks for “the 50 orders after this one,” every time, at the same cost regardless of how deep into the list that is.

Hit a pagination UI that genuinely needs both jump-to-page and cursor performance? Drop it in #scaling on Discord.

paginationpostgresperformancecursor-pagination