Mesrai
Back to blog
// essayTechnical Deep Dive

REST Pagination That Returns Inconsistent Page Sizes

Real PR catch: Prisma cursor pagination without `skip: 1` — cursor row duplicates. Skip + take-plus-one fix.

Mesrai TeamAugust 18, 20268 min read

A cursor-pagination endpoint. Client reports inconsistent page sizes — sometimes 19, sometimes 20, sometimes 21. Mesrai traced it to a missing `skip: 1` on the cursor branch.

The vulnerable diff

tsapi/feed.ts
// api/feed.ts
feedRouter.get("/feed", async (req, res) => {
  const cursor = req.query.cursor as string | undefined;
  // BUG: cursor is inclusive in Prisma — first row repeats from previous page
  const rows = await db.feed.findMany({
    orderBy: { id: "desc" },
    take: 20,
    cursor: cursor ? { id: cursor } : undefined,
  });
  res.json({ rows, nextCursor: rows.at(-1)?.id });
});

What is wrong

Prisma's `cursor` option includes the cursor row in the result. To paginate, you usually want the cursor row excluded — `skip: 1` when a cursor is provided. The bug is invisible if the client always shows page items uniquely (just looks like a duplicate); visible when the client dedupes or counts.

The attack

Symptom:

textexploit
Page 1: 20 rows, cursor = row20.id
Page 2 (cursor=row20.id): returns rows [20, 21, ..., 39] — row 20 repeats!
Client dedupes → page shows 19 items.

Off-by-one in the other direction (skip too many) gives gaps. Both look like inconsistent pagination.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 30 sec ago

[mesrai] [code-review] [Logic] [Pagination] [medium]

Prisma `cursor` is inclusive. Pagination needs `skip: 1` on the
cursor branch to exclude the cursor row:

  const rows = await db.feed.findMany({
    orderBy: { id: "desc" },
    take: 20,
    cursor: cursor ? { id: cursor } : undefined,
    skip:   cursor ? 1 : 0,
  });

Also consider the next-page detection pattern that uses take: limit+1:

  take: 21,                    // request one extra
  const hasMore = rows.length > 20;
  const items = rows.slice(0, 20);
  const nextCursor = hasMore ? items.at(-1)?.id : null;

Cleaner than a separate count query; the extra row is the boundary
indicator.

The fix

tsapi/feed.ts (fixed)
// api/feed.ts — fixed
feedRouter.get("/feed", async (req, res) => {
  const cursor = req.query.cursor as string | undefined;
  const rows = await db.feed.findMany({
    orderBy: { id: "desc" },
    take: 21,                                    // +1 for hasMore
    cursor: cursor ? { id: cursor } : undefined,
    skip:   cursor ? 1 : 0,
  });
  const hasMore = rows.length > 20;
  const items = rows.slice(0, 20);
  res.json({ rows: items, nextCursor: hasMore ? items.at(-1)?.id : null });
});

Skip the cursor row. Take one extra to detect hasMore. Consistent page size, no duplicates, no extra count query.

Why human review missed it

Cursor pagination off-by-one is easy to miss in unit tests because they often use small fixtures that don't trigger the duplicate. The bug shows up only on page 2+. Mesrai catches every `cursor:` Prisma call without `skip: 1` and recommends the standard pattern.

Related rules + further reading

Mesrai rule pack: logic/cursor-pagination-skip — flags Prisma cursor calls without skip:1 on the cursor branch.

Prisma docs: Pagination — Cursor-based.

Common in feed/timeline implementations.

Takeaway

Skip the cursor row. Take +1 for hasMore. Consistent pages. Mesrai catches the off-by-one.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.