With OFFSET paging over a live feed, why do users see duplicated rows?
answer
- each page is a separate query
- the underlying set changes between requests
- an insert ahead of you pushes a row down
- positions move, values do not
basics
~20 sOFFSET counts positions in a result recomputed on every request. Rows inserted ahead of the current position push everything down, so the next page repeats rows already shown; deletions pull rows up and skip them. Keyset anchors on a value, so the boundary stays fixed.
solid answer
~50 sEach page request is an independent statement evaluated against the table as it stands at that moment; nothing ties page 2 to the result set page 1 saw. With `ORDER BY created_at DESC LIMIT 10 OFFSET 10`, three rows inserted between the requests shift every earlier row down three positions, so positions 11–20 now hold three rows the user already read. Deletions do the reverse and skip rows entirely. On a busy feed this is not an edge case — it is the normal experience, and "why did the same item appear twice while scrolling?" is the usual bug report. Keyset pagination removes the whole class of problem, because the boundary is a value (`(created_at, id) < (:last_created_at, :last_id)`) rather than a count: new rows sorting ahead of the anchor cannot affect what comes after it. They show up when the user refreshes page one, which is what people expect.
code
sql · 12 lines-- Page 2 by position: three rows inserted since page 1 shift H, I, J back into view
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 10;
-- Page 2 by value: unaffected by anything inserted ahead of the anchor
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;go deeper
Know that each page is a separate query over data that may have changed, so OFFSET positions shift when rows are inserted or deleted. Duplicates and skips follow from that.
Walk the arithmetic: three inserts ahead of OFFSET 10 push three already-seen rows into positions 11 to 20. Explain why an anchor value avoids it and which case — a mutating sort key — it still cannot fix.
Own the diagnosis. Recognise the 'item appeared twice while scrolling' and 'export missed records' reports as offset drift, and argue the keyset rewrite as a correctness fix that happens to be faster, not merely an optimisation.
Decide where completeness is a requirement rather than a nicety. Batch exports, reconciliation and migrations get keyset traversal with a resumable anchor; user-facing listings can accept best-effort ordering, but say so explicitly rather than by accident.
## Why positions are not stable A paginated listing is not one query — it is a sequence of independent statements, one per user request, each evaluated against whatever the table contains at that instant. There is no continuous result set spanning the requests. `OFFSET` therefore counts positions in a set that is recomputed from scratch every time, over data that other users are changing in between. ## Insertions cause duplication Order a feed newest-first and page 10 at a time. The user fetches page 1 (`OFFSET 0`) and reads rows A..J. While they read, three new posts arrive. Those posts sort ahead of everything, so every previously seen row moves down three positions: A now sits at position 4, J at position 13. The user requests page 2 (`LIMIT 10 OFFSET 10`), which now returns positions 11–20 — that is rows H, I, J (already read) followed by K..Q. Three rows are shown twice. The rule generalises: any row inserted at a position ahead of your current offset causes exactly one already-seen row to reappear. ## Deletions cause silent skipping The mirror image is worse because nobody notices. If three rows ahead of the offset are deleted between requests, everything shifts up three positions, and positions 11–20 now begin three rows further along than the user's reading position. Rows K, L, M are never displayed to anyone. A duplicate is a visible annoyance; a skipped row is a support ticket months later, or a data-export job that quietly missed records. ## Sort-key mutation causes both at once Ordering by something that changes — a score, a `last_activity_at`, a priority — reshuffles rows between requests without any insert or delete. A row can move from position 30 to position 5 and be seen twice, or from 5 to 30 and be missed. This affects offset and keyset paging alike and is the one case keyset does not fix. ## Why keyset is stable Keyset pagination asks "what comes after this value?", and the answer does not depend on how many rows exist before the anchor: ```sql SELECT id, created_at, title FROM posts WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 10; ``` Three posts arriving mid-traversal sort ahead of the anchor, so they are outside the predicate entirely and change nothing about the next page. The user continues exactly where they stopped and sees the new posts when they return to the top — the behaviour every feed UI already implies. Deletions behind the anchor are equally harmless: the remaining rows keep their values, so the boundary still means the same thing. One genuine subtlety: if the anchor row itself is deleted between requests, keyset still works. The predicate compares values, not identity, so it selects everything sorting after those values whether or not a row with them still exists. That is a nice property — a server-side cursor pinned to a row id would break. ## What keyset does not fix - **Mutating sort keys.** Order by a value that changes and rows still move across the boundary. If stability matters, sort by something immutable (creation time plus id) and display the volatile value as data rather than sorting by it. - **Rows changing so they no longer match the filter.** A row that stops satisfying the `WHERE` clause disappears from the traversal, keyset or not. - **Rows inserted behind the anchor.** A back-dated insert lands in a region the user has already passed and will not be shown. That is inherent to any incremental traversal; only re-reading from the start surfaces it. ## How to talk about it in an interview Frame it as a correctness problem, not only a performance one. Deep `OFFSET` is slow; *any* `OFFSET` over changing data is unstable, including shallow pages. That is why the recommendation to move a feed to keyset pagination stands even when the table is small enough that the cost argument does not apply. When the product truly needs numbered pages over volatile data, be explicit that the pages are a best-effort view of a moving target, and keep loops that must see every row — exports, reconciliation, migrations — on keyset traversal, where completeness is guaranteed.
- What happens with keyset pagination if the anchor row is deleted between requests?Nothing breaks. The predicate compares values, not row identity, so it still selects everything sorting after those values whether or not a row with them exists. This is an advantage over a server-side cursor pinned to a row id, which would have to handle the missing anchor explicitly.
- Does keyset pagination guarantee a user sees every row exactly once?Only if the sort key is immutable and rows are not back-dated into the region already passed. It guarantees no drift from inserts and deletes ahead of the anchor, which is the common case. Ordering by a mutating value — a score or last_activity_at — still lets rows cross the boundary and be duplicated or missed.
- An export job must read every row of a large table exactly once. What do you use?Keyset traversal over the primary key: `WHERE id > :last_id ORDER BY id LIMIT :batch`, looping until fewer than :batch rows come back. It is constant-cost per batch, complete under concurrent inserts of higher ids, and resumable after a crash by storing the last id — none of which an OFFSET loop provides.
saying these in an interview costs you the question
- Blames caching or the client for repeated rows
- Thinks a bigger page size makes the drift go away
- Believes OFFSET is only a performance problem, never a correctness one
- Claims keyset pagination is immune even with a mutating sort key
- Proposes holding a transaction open across page requests