Skip to content

Pagination

Why OFFSET gets slower the deeper you go, how cursors fix it, and the bug that silently skips rows.

Intermediate17 minUpdated 27 September 2026

Every list endpoint starts the same way:

SELECT * FROM posts ORDER BY created_at DESC;

With 200 rows this is fine. With two million it is a catastrophe: the database reads the entire table, your server buffers it all in memory, and the client waits thirty seconds for a response it will render 20 items of.

So you paginate. The obvious approach has two problems that only appear at scale, and most engineers never learn about either until production teaches them.

The obvious approach: offset

SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;

"Skip 40 rows, give me the next 20." It maps perfectly onto page numbers: page 3 with a page size of 20 means OFFSET 40. Every ORM supports it, and it powers the numbered page links users recognise.

Problem one: it gets slower the deeper you go

Here is the part that surprises people. OFFSET 40 does not let the database jump to row 41. There is no way to know where row 41 is without counting.

So the database reads and discards rows 1 to 40, then returns the next 20.

Now consider OFFSET 1000000. The database reads a million rows, throws every one away, and returns twenty. The work grows linearly with page depth:

PageOffsetRows readTypical time
1020~1ms
501,0001,020~5ms
5,000100,000100,020~200ms
50,0001,000,0001,000,020~2s or worse

Page one is instant. Page 50,000 may time out. And this is not hypothetical — crawlers and scrapers walk deep into paginated lists routinely, so your slowest queries get requested constantly by the traffic you care least about.

Problem two: it silently skips rows

This one is worse, because it corrupts results rather than merely being slow.

A user reads page 1 (posts 1–20). While they read, three new posts are created. Every existing post shifts three positions down. The user clicks "next page" and gets OFFSET 20.

Posts that were at positions 18, 19 and 20 have moved to 21, 22 and 23 — so they appear again on page 2. Meanwhile, on a list sorted the other way, rows can be skipped entirely and the user never learns they existed.

Conceptual diagramPage 1 read: items 1-203 new items inserted at topPage 2 requested: OFFSET 20Old items 18,19,20 now at 21,22,23shown twice
Diagram, as text
  • Page 1 read: items 1-20 leads to 3 new items inserted at top
  • 3 new items inserted at top leads to Page 2 requested: OFFSET 20
  • Page 2 requested: OFFSET 20 leads to Old items 18,19,20 now at 21,22,23 shown twice

On a changing dataset, offset pagination does not give a stable view. For a blog archive that is a curiosity. For a payments export or an infinite-scroll feed, it is a bug that produces duplicated or missing records.

The better approach: cursor pagination

Instead of "skip N rows", say "give me what comes after this specific row".

SELECT * FROM posts
WHERE created_at < '2024-06-10 12:00:00'
ORDER BY created_at DESC
LIMIT 20;

The client sends back the last value it saw — the cursor — and the database jumps straight there using the index. No counting, no discarding.

Both problems vanish:

  • Constant time. With an index on created_at, finding the starting point is a seek, not a scan. Page 50,000 costs the same as page 1.
  • Stable results. New posts inserted at the top do not shift the position of the row you anchored to, so nothing duplicates or disappears.

Making the cursor correct

A naive cursor breaks when two rows share the same timestamp — you either skip or repeat the tied rows. Make the cursor unique by adding a tiebreaker:

SELECT * FROM posts
WHERE (created_at, id) < ('2024-06-10 12:00:00', 8842)
ORDER BY created_at DESC, id DESC
LIMIT 20;

The sort key must be unique and match the index, otherwise ordering is ambiguous and pagination is unreliable.

In the API, encode the cursor as an opaque string so clients cannot construct their own and do not depend on its internals:

{
  "data": [ ... ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTA2LTEwVDEyOjAwOjAwWiIsImlkIjo4ODQyfQ=="
}

Choosing between them

OffsetCursor
Jump to page 500YesNo — sequential only
Show "page 7 of 340"YesNot without a separate count
Performance at depthDegrades linearlyConstant
Stable while data changesNoYes
ImplementationTrivialModerate

Use offset for admin tables and back-office screens where users genuinely want numbered pages, datasets are small, and data changes slowly.

Use cursors for infinite scroll, feeds, public APIs, exports, and anything where the dataset is large or actively changing.

Details that matter in practice

Always cap the page size. If a client sends ?limit=1000000, you are back to the original problem. Clamp it: const limit = Math.min(Number(req.query.limit) || 20, 100).

Always define a stable sort. Without ORDER BY, databases may return rows in any order, and pagination becomes meaningless. Include a unique tiebreaker.

Total counts are expensive. SELECT COUNT(*) on a large table scans it. If you need "340 pages", consider caching the count, showing an approximation, or simply returning hasMore: true — which is all infinite scroll actually needs.

Index the sort column. Cursor pagination without an index on the ordering column still scans the table, and you have gained nothing.

Check your understanding

Common mistakes

Returning everything and paginating in application code. Fetching a million rows to slice twenty in JavaScript moves the cost to your server. Paginate in the query.

Assuming offset is fine because the table is small today. Tables grow. The endpoint that scans 800 rows becomes the one scanning 8 million, usually on a Friday.

Cursors over a mutable sort key. If you paginate by updated_at and rows are being updated, the anchor moves under you. Cursor keys should be immutable — creation time plus id is a safe default.

Forgetting the last page. Decide what nextCursor is when there are no more results — null is clearer than an empty string, and clients must handle it.

Practice

An endpoint offers `?page=N` and an analytics team requests page 5,000 of a 2-million-row export. Explain what the database does and design a better export path.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Ask what OFFSET actually costs, then question whether pagination is the right tool for an export.

Show a strong answer

What the database does: OFFSET 99980 LIMIT 20 does not seek to row 99,980. It reads and discards 99,980 rows first, every time. Page 5,000 costs roughly 5,000 times more work than page 1 for the same 20 rows, and the analytics team will request all 100,000 pages — so the total work is quadratic in the number of pages.

It is also wrong, not merely slow: over the hours that export takes, rows are inserted and deleted, so pages drift and the export both duplicates and misses rows.

A better export path — stop treating it as pagination.

  • Keyset iteration. Order by a unique key and pass the last key seen: WHERE id > :last ORDER BY id LIMIT 5000. Each batch is an index seek, so batch 5,000 costs the same as batch 1, and the cost is linear overall.
  • Snapshot the read. Run the whole export inside one repeatable-read transaction, or against a read replica pinned to a point in time, so the result is a consistent picture rather than a smear across three hours.
  • Make it asynchronous. An export is a job, not a request. Accept it, return a job ID, write the output to object storage, and give back a download link when it is done. This removes the timeout problem entirely and stops a large export competing with user traffic.
  • Larger batches. 20 rows per round trip is right for a user interface and wrong for a bulk read; a few thousand amortises the per-query cost.

The general point: page-numbered pagination is a user-interface affordance. Bulk data movement is a different problem and borrowing the interface for it produces both bad performance and incorrect results.

Design pagination for a search results page that must show 'about 4,300 results' and let users jump to page 7. Cursor pagination cannot do either. Resolve this.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Separate what the interface promises from how the data is fetched.

Show a strong answer

Acknowledge the conflict honestly: cursors give correctness and constant cost but cannot count and cannot jump. Offsets can do both and are inconsistent and slow at depth. The requirements here are genuinely offset-shaped, so the answer is not to insist on cursors.

What I would build:

  • Offset pagination, deliberately bounded. Allow jumping to any page within the first ~50, and stop there. Nobody browses to page 500 of a search — beyond a few pages, users refine the query instead. A hard cap keeps the worst-case offset small enough that the cost never becomes pathological, and it is not a limitation anyone notices.
  • An approximate total, presented as approximate. "About 4,300 results" is what the interface already says, and an estimate from the index or a capped count ("1,000+") is far cheaper than an exact count over millions of rows. Exact counts are expensive precisely when the result set is large, which is when nobody reads the number.
  • Accept the inconsistency, because search tolerates it. An item shifting between pages while new content is indexed is unremarkable in search results, unlike in a financial ledger. The requirement to be exactly consistent is not present here — worth stating explicitly rather than assuming.
  • Sort by relevance with a stable tiebreaker, so identical scores do not reorder between requests and produce phantom duplicates.

Where I would still use a cursor: the infinite-scroll variant on mobile, which is genuinely sequential. It is entirely reasonable for one dataset to expose both — numbered pages for the desktop search page, a cursor for the scrolling client — because they are different access patterns with different guarantees.

Key takeaways

  • OFFSET reads and discards every row it skips, so query cost grows linearly with page depth. Deep pages get slow, and crawlers request them constantly.
  • On changing data, offset pagination duplicates or skips rows, because it counts positions rather than identities.
  • Cursor pagination anchors to a specific row, giving constant-time seeks and a stable view regardless of inserts.
  • Cursor keys must be unique, immutable and indexed — typically a timestamp plus a tiebreaking id.
  • Use offset for numbered admin tables, cursors for feeds, exports and public APIs — and consider capping offset depth.
  • Always clamp the page size and define an explicit ORDER BY, or pagination is meaningless.

Further reading

  • Markus Winand, SQL Performance Explained (2012) — keyset pagination and why OFFSET degrades.
  • Markus Winand, "We need tool support for keyset pagination" (2013).
  • RFC 8288, Web Linking — the Link header for next and previous page relations.
  • Facebook, "Cursor-based Pagination" (Graph API documentation) — opaque cursors in a public API.
  • Joe Celko, SQL for Smarties, fifth edition (Morgan Kaufmann, 2014) — ordering, ties and stable sorts.

Check yourself

3 questions in this lesson. Answer each once to complete it.

No problem practices this lesson yet. Browse the practice problems

Next lessonError Handling
Lessons in this track