Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Page Two Already Moved

  • laravel
  • typescript
  • databases
  • apis

A user loads page one of the activity feed. Two new rows land above them while they're reading. They hit page two and see the last item from page one again. Or they miss a row that got shoved out of the window by a delete.

Nothing is "wrong" with the query. The window moved.

OFFSET means "skip whatever is there now"

Classic pagination asks the database for a sorted slice and then skips ahead by a page number:

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

OFFSET 40 means "ignore the first 40 rows in the current result." It does not mean "continue after the last row I already saw." If two new posts arrive at the top while the user is on page one, the old page-one rows slide down. Page two now starts two rows earlier than it did a second ago. Duplicates for the user. Gaps for anyone paging the other direction.

Deletes do the opposite. A row vanishes above the window and something the user hasn't seen yet jumps into the previous page. They never get it.

That drift is fine for a mostly static admin table. It is a constant annoyance for feeds, notifications, search results that keep updating, and any public API where clients page over a live set.

The framework default is OFFSET

Laravel's paginate() (and simplePaginate()) builds an OFFSET query under the hood:

$posts = Post::query()
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->paginate(20);

In TypeScript with Prisma, skip / take is the same idea:

const page = 3;
const pageSize = 20;
 
const posts = await prisma.post.findMany({
  orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
  skip: (page - 1) * pageSize,
  take: pageSize,
});

Both are correct for "give me page N of a stable list." Both will quietly reshuffle when the sorted set changes between requests.

Keyset pagination holds the place

Keyset pagination (also called seek or cursor pagination) does not skip by count. It asks for rows on the far side of a known position:

SELECT * FROM posts
WHERE (created_at, id) < ('2026-11-07 14:02:11', 1842)
ORDER BY created_at DESC, id DESC
LIMIT 20;

New inserts above the cursor stay above the cursor. Deletes above it do not pull later rows into a previous page. The client keeps a bookmark, not a page number.

That is not a snapshot. If an existing row changes its sort value and crosses the cursor, it can still be missed or seen twice. Prefer immutable ordering values for a feed. If an export must include exactly the rows that existed when it started, use an actual snapshot or materialize the result set.

Laravel ships this as cursorPaginate():

$posts = Post::query()
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->cursorPaginate(20);

The response includes a cursor the client sends back for the next page. If you want the seek explicit (handy for APIs that own their own cursor encoding), a manual where works the same way:

$posts = Post::query()
    ->where('id', '<', $cursor)
    ->orderByDesc('id')
    ->limit(20)
    ->get();

Use a compound comparison when the sort is not a unique id alone (where on created_at plus id, or a tuple comparison) so two rows with the same timestamp do not stutter or skip.

The same pattern in TypeScript

Prisma's cursor API is built for this. Pass the last row's id, skip that row itself, and take the next page:

const posts = await prisma.post.findMany({
  orderBy: { id: 'desc' },
  cursor: { id: lastId },
  skip: 1,
  take: 20,
});

Or write the seek yourself if you're on raw SQL / Drizzle-style builders:

const posts = await db
  .select()
  .from(postsTable)
  .where(lt(postsTable.id, cursor))
  .orderBy(desc(postsTable.id))
  .limit(20);

Encode the cursor as an opaque string for clients (base64 of created_at + id is common). Do not let them invent page numbers against a live feed and pretend the math still holds.

When OFFSET is still the right tool

Cursors are not free. You need a stable, unique sort order. Tie-break with id (or another unique column) whenever the primary sort can collide. "Jump to page 47" is awkward: there is no cheap way to seek by page index without walking or keeping a side index. Total page counts are also gone unless you run a separate COUNT(*), which you often do not want on a hot table anyway.

So keep OFFSET where the list is mostly stable and people actually want page numbers:

  • Admin tables with filters and a "page 12 of 40" UI
  • Sorted catalogs that rarely change while someone is browsing
  • Reports over a fixed, materialized result set

For activity feeds, infinite scroll, notification inboxes, and public list APIs that keep receiving writes, prefer keyset. Same rule of thumb as batch jobs that use chunkById() instead of offset chunk(): pin to a key, not to a row count that moves under you.

Rule of thumb

If the answer to "can rows appear or disappear above the current window while someone is paging?" is yes, do not paginate with OFFSET. Hand the client a cursor, seek past the last row they saw, and let page numbers stay on the boring admin screens where they belong.

← All posts