databases/pagination-why-next-page-shows-a-post-twice-and-how-a-bookmark-fixes-i.md

Pagination: why 'next page' shows a post twice, and how a bookmark fixes it

Databases · 5 min read ·

Why does "next page" sometimes show the same post twice, and why does page 90,000 load slowly? Both come from paging with LIMIT and OFFSET, which counts rows instead of remembering a place.

Try both styles side by side in a small feed, watch a new post sneak in and cause a repeat, then see the fix: keyset pagination, which uses the last row you saw as a bookmark. Includes real SQLite code and a measured timing comparison.

Two columns of post cards: the OFFSET column repeats post 31 highlighted in orange, the bookmark column continues with 30, 29, 28

You scroll a feed, tap "next page", and see the same post twice. Or a table of a million rows loads page 1 instantly but page 90,000 takes a noticeable pause. Both come from the same habit: asking the database for "page N" by counting rows. This article shows why counting is fragile, and what to do instead.

The usual way: LIMIT and OFFSET

A database stores rows in tables, and SQL is the language you use to ask for them. To show 5 posts at a time, newest first, the common recipe uses two keywords. LIMIT 5 means "give me at most 5 rows". OFFSET 5 means "skip the first 5 rows of the answer".

-- page 1
SELECT id, title FROM posts ORDER BY id DESC LIMIT 5 OFFSET 0;
-- page 2
SELECT id, title FROM posts ORDER BY id DESC LIMIT 5 OFFSET 5;
-- page 3
SELECT id, title FROM posts ORDER BY id DESC LIMIT 5 OFFSET 10;

It reads naturally: page number times page size gives the offset. The trouble is that "skip 5 rows" means "skip whatever the first 5 rows are right now".

Problem 1: the list moves under you

Imagine a table with posts 1 to 12. Page 1 (newest first) shows 12, 11, 10, 9, 8. Before you tap "next", someone publishes post 13. Now the newest-first list is 13, 12, 11, 10, 9, 8, 7... and "skip 5" lands one row too early. I ran exactly this in SQLite:

page 1:        [12, 11, 10, 9, 8]
(post 13 is added)
page 2 OFFSET: [8, 7, 6, 5, 4]    -- 8 appears twice!

Post 8 is shown twice. If a post had been deleted instead, the list would shift the other way and one post would be skipped, never shown at all. Nothing is broken in the database; it answered the question you asked. The question was just the wrong one.

Problem 2: skipping still costs work

The database can't jump straight to row 900,000. To "skip" rows it has to walk past them first and throw them away. So OFFSET 900000 does the work of reading 900,000 rows to return 10. The deeper the page, the slower it gets.

The fix: remember where you stopped

Instead of "skip 5 rows", say "give me the rows after the last one I saw". This is called keyset pagination (also "cursor" or "seek" pagination). The last row you showed is your bookmark:

-- you last saw id 8
SELECT id, title FROM posts
WHERE id < 8
ORDER BY id DESC
LIMIT 5;

Run on the same data after post 13 appeared, this returns [7, 6, 5, 4, 3]: no repeat, no gap, because the bookmark is a value, not a count. And because id is a primary key (which databases keep in sorted order, like the index in a book), the database can jump to "id below 8" directly instead of walking from the top.

Try it: both styles side by side

Below is a feed of posts. Load a few pages on each side, then press "New post arrives" and load another page. Watch the left column for repeats, and watch the "rows read" counter on each side.

Measured: how much slower is a deep page?

I built a table of 1,000,000 posts in SQLite (version 3.53 in the test) and asked for 10 rows around position 900,000:

QueryTime (this machine)Query plan
OFFSET 900000about 6 msSCAN posts
WHERE id < 100001about 0.03 msSEARCH posts USING INTEGER PRIMARY KEY

Your numbers will differ, and SQLite in memory is fast, so 6 ms looks harmless. The point is the ratio (roughly 200 times here) and the shape: OFFSET gets slower the deeper you go, keyset stays flat. A server database over a network, with bigger rows, makes the gap larger. The plan words show the reason: SCAN walks rows, SEARCH jumps straight to a spot.

What keyset pagination can't do

Nothing is free. Keyset has trade-offs worth knowing:

  • You can't jump to "page 47". You can only go to the next page from where you are (or the previous one, with the reverse condition).
  • The sort column must be unique, or ties will cause skips. If you sort by something like date, add id as a tie-breaker: WHERE (created_at, id) < (?, ?). Many databases, including SQLite since 3.15, support comparing pairs like this.
  • For fast jumps, the sort columns need an index. A primary key already has one.

So which should you use?

For an admin table with a few hundred rows where people want "go to page 7", OFFSET is fine and simpler. For an endless feed, a big table, or anything where rows are added while people read, use a bookmark. When you build an API for a feed, hand the client an opaque "next cursor" value (the last id, encoded) instead of a page number, so you can change how it works later.

$ ls databases/

see all →
databases/caching-why-the-second-read-is-fast-and-how-a-copy-goes-stale.md Caching: why the second read is fast, and how a copy goes stale Why does a page load slowly the first time and fast the second? Usually a cache: a quick copy of an answer the database already gave. Run a… 5 min read · 29 Oct databases/the-n-1-query-problem-why-a-page-that-asks-the-database-501-questions.md The N+1 query problem: why a page that asks the database 501 questions is slow A page that lists authors and their books can quietly send one query per author. With 500 authors that is 501 trips to the database, and… 4 min read · 26 Oct databases/transactions-why-a-money-transfer-must-save-both-changes-or-neither.md Transactions: why a money transfer must save both changes or neither Sending £50 between two accounts takes two changes to a database. If the power fails between them, the money vanishes. A transaction groups… 5 min read · 23 Oct databases/sql-indexes-why-finding-one-row-among-200-000-takes-about-18-steps.md SQL indexes: why finding one row among 200,000 takes about 18 steps A database with 200,000 customers can answer "find this email" by reading every row, or by reading about 18 entries. The difference is an… 4 min read · 20 Oct databases/sql-group-by-how-many-rows-become-one-row-per-customer.md SQL GROUP BY: how many rows become one row per customer A shop's orders table has 8 rows, but the owner wants one line per customer. GROUP BY sorts rows into piles by a column's value, then an… 4 min read · 17 Oct databases/sql-null-why-two-people-vanish-from-both-your-queries.md SQL NULL: why two people vanish from both your queries Two people have no age in your table. You ask for everyone older than 30, then for everyone not older than 30, and the two lists add up to… 4 min read · 14 Oct