FSD Frontend System Design

Part V β€” The Craft Β· Lesson 10.11

Pagination Part-1

In one lineOffset is simple and drifts; cursor is stable and can't jump to page 7 β€” pick by what the data does.

Where you've seen itClicking "next" on a busy feed and seeing the same post again, because new items shifted everything down.

  • Time16 min
  • DiagramOffset drift vs cursor stability
  • Chapter10 Β· Low Level Design

Diagram

diagram
OFFSET DRIFT β€” the bug that looks like a duplicate render

  t=0   GET /posts?offset=0&limit=3
        [ P9 ][ P8 ][ P7 ]            user reads page 1

  t=1   3 new posts arrive: P12, P11, P10

  t=2   GET /posts?offset=3&limit=3
        list is now:
        [P12][P11][P10][P9][P8][P7][P6]…
         0    1    2    3   4   5
                        └───┴───┴── returns P9, P8, P7 AGAIN

  Insertions shift every offset. Deletions skip items entirely.
diagram
CURSOR β€” position defined by the data, not by a count

  t=0   GET /posts?limit=3
        [ P9 ][ P8 ][ P7 ]   nextCursor: "P7"

  t=1   3 new posts arrive (irrelevant β€” cursor points at P7)

  t=2   GET /posts?limit=3&after=P7
        [ P6 ][ P5 ][ P4 ]   βœ“ no duplicates, no skips

  The cursor is usually an opaque encoding of
  (sortValue, tiebreakerId) β€” e.g. base64("2026-07-30T10:00Z|P7")
diagram
CHOOSING

                        OFFSET            CURSOR
  jump to page 7          βœ“                 βœ—
  total page count        βœ“                 usually βœ—
  stable under inserts    βœ—                 βœ“
  deep-page performance   βœ— O(n) scan       βœ“ indexed seek
  URL shareability        βœ“ ?page=7         partial (opaque token)
  implementation cost     trivial           moderate

  admin tables, search results  ─► OFFSET
  feeds, chat, infinite scroll  ─► CURSOR

How it works

  1. Offset: LIMIT 20 OFFSET 200. Simple, allows page numbers, and requires the database to walk 200 rows before returning any β€” slow at depth.
  2. Cursor: WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 20. Indexed seek, constant cost, immune to insertions.
  3. Always include a tiebreaker. Sorting on a non-unique column alone (timestamps collide) drops or repeats rows at page boundaries.
  4. Keep the page state in the URL (6.10) β€” ?page=3 or ?after=<cursor> β€” so refresh and sharing work.
  5. Prefetch the next page on hover or when the user nears the end (5.5), making the transition feel instant.

Trade-offs

Use offset whenUse cursor when
Data is stable (reports, archives)Items are inserted at the top
Users need page numbersInfinite scroll (10.7)
The dataset is smallDeep pagination performance matters
Total count is a product requirementDuplicates are unacceptable

A middle path: cursor for the data, plus an approximate total ("about 12,000 results"), which is what most large search UIs actually do.

Interview angle

"Why did the same item appear on two pages?"

  • Offset pagination on a list that receives inserts β€” every new row at the top shifts the window by one.
  • Cursors fix it because position is defined by the row you last saw, not by a count.
  • Keep a unique tiebreaker in the sort key, or identical timestamps will still cause skips at boundaries.

Recap

  • Offset drifts under inserts and gets slower with depth; cursor is stable and constant-cost.
  • Always sort by (value, unique id) so page boundaries are deterministic.
  • Page state belongs in the URL; the next lesson builds the component.