Postgres OFFSET Pagination Slow on Deep Pages: Use Keyset

Postgres · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: Page 1 loads in 3ms but page 4,000 takes seconds, and rows get skipped or duplicated while users scroll. Keyset pagination fixes both problems.

The Problem

Your admin table pages through orders with LIMIT 50 OFFSET $n, and Postgres OFFSET pagination gets slow on deep pages. Page 1 returns in 3ms. Page 200 takes 60ms. Page 4,000 (OFFSET 200000) takes 1.8s and pins a CPU core. Meanwhile a scraper walking every page drives the database to 90% load.

There is a second, quieter bug. New orders arrive while a user is on page 3. When they click to page 4, every row has shifted down. They see five orders they already saw. If a row was deleted instead, they silently miss one.

Why the Obvious Fix Falls Short

The usual first move is adding an index on created_at. That helps the sort, and the plan switches from Sort to Index Scan. Deep pages are still slow.

The reason is that OFFSET 200000 has no way to jump. Postgres must walk 200,000 index entries, fetch each heap tuple to check visibility, and then throw all of them away before returning your 50 rows. The index only removed the sort; the walk remains. Cost is O(offset + limit), so every page is slower than the one before it.

The second move is caching page results or a COUNT(*). That serves stale pages and makes the duplicate and skip problem worse. Raising work_mem doesn't help either, because nothing here is spilling to disk.

How It Actually Works

Keyset (seek) pagination stops counting rows. It tells the index where to start. You remember the sort key of the last row you returned and ask for rows strictly after it:

WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50

With a composite index on (created_at DESC, id DESC), Postgres descends the B-tree directly to that position in O(log n), then reads exactly 50 entries. Page 4,000 costs the same as page 1. The cursor is anchored to a value, not a position, so inserts and deletes elsewhere can't shift it.

The id tie-breaker matters. Without it, rows sharing a created_at straddle page boundaries and get skipped.

flowchart TD
    subgraph OFFSET["OFFSET 200000 LIMIT 50"]
      A1[Index root] --> A2[Leftmost leaf]
      A2 --> A3["Walk 200,000 entries<br/>+ heap visibility checks"]
      A3 --> A4[Discard 200,000 rows]
      A4 --> A5[Return 50 rows]
    end
    subgraph KEYSET["WHERE (created_at,id) < cursor LIMIT 50"]
      B1[Index root] --> B2["Binary descent to cursor<br/>~3-4 page reads"]
      B2 --> B3[Read next 50 entries]
      B3 --> B4[Return 50 rows]
      B4 --> B5["Emit cursor = last row's<br/>(created_at, id)"]
      B5 -. next request .-> B1
    end

Before and After

-- BEFORE: cost grows linearly with page number;
-- inserts shift rows between pages.
SELECT id, customer_id, total, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 50 OFFSET 200000;  -- walks and discards 200k rows
-- AFTER: index seek to the cursor, constant cost per page.
CREATE INDEX CONCURRENTLY orders_created_id_idx
  ON orders (created_at DESC, id DESC);  -- must match ORDER BY exactly

-- First page: no WHERE clause.
-- Later pages: pass the last row's values back as the cursor.
SELECT id, customer_id, total, created_at
FROM orders
WHERE (created_at, id) < ($1, $2)       -- row comparison = tie-safe seek
ORDER BY created_at DESC, id DESC       -- id makes ordering total
LIMIT 50;

In the API, return an opaque cursor such as base64(created_at|id) instead of a page number. To detect the end of results, fetch LIMIT 51: if you get 51 rows, there is a next page.

When NOT to Use This

Gotchas

Key takeaway: Paginate with `WHERE (sort_col, id) < (last_sort, last_id) ORDER BY sort_col DESC, id DESC LIMIT n` on a matching composite index, so every page costs the same as page 1.

Real-world challenge

You moved an infinite-scroll activity feed from OFFSET to keyset pagination on `(created_at, id)`. Page latency is now flat at about 4ms. However, QA notices that bulk-imported events sometimes vanish between pages, and the problem appears only when many rows share nearly the same timestamp. The Node.js API builds the cursor as `{ createdAt: row.created_at.toISOString(), id: row.id }` and passes `createdAt` back as a query parameter on the next request.

Diagnosis

Postgres timestamptz has microsecond precision. A JavaScript Date keeps only milliseconds, so 2024-05-01 10:00:00.123456 becomes ...00.123.

The next query asks for (created_at, id) < ('...00.123', 9001). Every row at .123001 through .123999 is now greater than the truncated cursor. Those rows get skipped, regardless of their id. Bulk imports produce many rows within one millisecond, which is why only they disappear.

Fix

Keep the cursor at full precision. Choose one of these approaches:

SELECT id, payload, created_at::text AS cursor_ts
FROM events
WHERE (created_at, id) < ($1::timestamptz, $2)
ORDER BY created_at DESC, id DESC
LIMIT 50;

With pg you can also run types.setTypeParser(1184, v => v) to keep timestamptz as a string. Then base64-encode cursor_ts and id into an opaque cursor so clients can't tamper with them.