Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Your Pagination Is Showing Users the Same Item Twice (and Skipping Others). Here's Why Offset Breaks

A user scrolls through your feed. Page 1 ends with "Post 20". They tap "Next", and page 2 starts with... "Post 20" again. Another user pages through an admin table and never sees order #4817. It exists. It is in the dat

A user scrolls through your feed. Page 1 ends with "Post 20". They tap "Next", and page 2 starts with... "Post 20" again.

Another user pages through an admin table and never sees order #4817. It exists. It is in the database. It just never showed up on any page.

Nobody changed any code. The data changed underneath the pagination, and your skip and limit could not cope.

The Code Everyone Writes

app.get('/api/posts', async (req, res) => {
  const page = Number(req.query.page) || 1;
  const limit = 20;

  const posts = await Post.find()
    .sort({ createdAt: -1 })
    .skip((page - 1) * limit)
    .limit(limit);

  res.json(posts);
});

It is simple, it works in development, and ?page=2 is easy to understand. That is why it is everywhere.

Why It Breaks

Offset pagination says "skip the first N rows, then give me the next 20". It counts positions, and positions move when the data does.

Duplicates. Someone publishes a new post while a reader is on page 1. Every existing post shifts down one position. When the reader requests page 2, the last item of page 1 is now the first item of page 2.

Skips. The reverse: a post is deleted while the reader is on page 1. Everything shifts up one, and the first item of what would have been page 2 slides into page 1's range. The reader never sees it.

Before:  [1 2 3 4 5 | 6 7 8 9 10]    page 1 = 1-5, page 2 = 6-10
New item arrives at the top:
After:   [0 1 2 3 4 | 5 6 7 8 9]     page 2 now starts with 5, which they already saw

The Second Problem: It Gets Slower on Deep Pages

skip(100000) does not jump to row 100,000. The database has to walk past 100,000 documents and throw them away before returning your 20. Page 1 is fast. Page 5,000 is slow, and the cost grows with the page number.

This is why a feed that feels fine with 1,000 rows slows down quietly at a million.

The Fix: Cursor Pagination

Instead of "skip N", say "give me the 20 items after this one". The anchor is a cursor: a value that identifies where the last page ended.

A new item arriving at the top cannot shift your place, because you are not counting positions. You are standing on a specific item.

Step 1: Sort by something stable, with a tie-breaker

createdAt alone is not enough, because two documents can share the same timestamp. Add _id as a tie-breaker so the order is always exact:

.sort({ createdAt: -1, _id: -1 })

Step 2: Support the query with an index

PostSchema.index({ createdAt: -1, _id: -1 });

With this index, the database jumps straight to the cursor and reads 20 documents, whether you are on page 1 or page 5,000.

Step 3: Query "after the cursor"

app.get('/api/posts', async (req, res) => {
  const limit = 20;
  const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;

  const filter = cursor
    ? {
        $or: [
          { createdAt: { $lt: cursor.createdAt } },
          { createdAt: cursor.createdAt, _id: { $lt: cursor._id } },
        ],
      }
    : {};

  const rows = await Post.find(filter)
    .sort({ createdAt: -1, _id: -1 })
    .limit(limit + 1); // fetch one extra to know if there is a next page

  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  const last = items[items.length - 1];

  res.json({
    items,
    nextCursor: hasMore ? encodeCursor(last) : null,
  });
});

The $or says: "older than the cursor, or the same timestamp but a smaller _id". That second branch is what stops items with identical timestamps from being skipped or repeated.

Step 4: Encode the cursor so clients do not depend on it

const encodeCursor = (doc) =>
  Buffer.from(JSON.stringify({ createdAt: doc.createdAt, _id: doc._id })).toString('base64url');

const decodeCursor = (str) => {
  const { createdAt, _id } = JSON.parse(Buffer.from(str, 'base64url').toString());
  return { createdAt: new Date(createdAt), _id: new mongoose.Types.ObjectId(_id) };
};

Clients treat it as an opaque string and send it back as it is. You can change what is inside later without breaking anyone. In production, validate the decoded values and handle a malformed cursor with a 400, not a crash.

What You Give Up

Cursor pagination has a trade-off, and you should know it before switching:

  • No "jump to page 47". You can only go forward (or back, with a second cursor). For feeds and infinite scroll, that is perfect. For a numbered page bar, it is not.
  • No cheap total count. countDocuments on a large collection is not free either way, so many apps drop the "page 3 of 500" display.

When Offset Is Still Fine

Do not rewrite everything. Offset pagination is a reasonable choice for small, slow-changing tables, like an admin list with a few hundred rows, where users really do want page numbers.

Situation Use
Feed, timeline, infinite scroll Cursor
Large collection, deep pages Cursor
Data changes while people browse Cursor
Small admin table with page numbers Offset is fine

The Rule

Offset pagination counts positions, and positions change. Cursor pagination anchors to a specific item, so it stays correct when data is added or removed. Always sort by a unique, indexed combination, and fetch one extra row to know whether there is a next page.

Quick audit: search your project for .skip(. For each hit, ask whether the collection can grow past a few thousand documents, or change while someone is paging through it.

If you work with MongoDB, I collect more patterns like this, around indexes and queries, in my MongoDB and Mongoose patterns guide, and you can browse my Next.js templates on pixelanas.com.

Get the templates: https://pixelanas.gumroad.com

Have you ever been bitten by duplicate or missing items in a paginated list? Tell me how you noticed in the comments.

Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.