Why does this cursor pagination helper repeat the last item on the next page?

Hey everyone, I’m working on a small API helper for cursor pagination, and I’m trying to keep it simple without loading extra rows in memory. The problem is I either miss an item or repeat the last one from the previous page.

function getPage(items, cursor, limit) {
  let start = 0;

  if (cursor !== null) {
    start = items.findIndex(item => item.id >= cursor);
  }

  const page = items.slice(start, start + limit);
  const nextCursor = page.length ? page[page.length - 1].id : null;

  return { page, nextCursor };
}

What am I getting wrong with the cursor boundary here if I want stable pages without duplicates?

Sora

Your findIndex(item => item.id >= cursor) is currently doing inclusive paging, not start-after-cursor paging. That repeats the previous page’s last item instead of moving past it.

A cursor here should mean “start after this id,” so the comparison needs to be exclusive. There’s also a small edge case: if nothing is greater than cursor, findIndex returns -1, and slice(-1, .) pulls from the end of the array. Tiny mental model: the cursor is a fencepost, not a bookmark.

The smallest fix is this:

function getPage(items, cursor, limit) {
  let start = 0;

  if (cursor !== null) {
    start = items.findIndex(item => item.id > cursor);
    if (start === -1) start = items.length;
  }

  const page = items.slice(start, start + limit);
  const nextCursor = page.length . page[page.length - 1].id : null;

  return { page, nextCursor };
}

Tiny version of the boundary change:

item.id > cursor

With item.id > cursor and start = items.length in place:

  • item.id > cursor skips the last item from the previous page.
  • start = items.length ensures an exhausted cursor returns an empty page instead of the tail item.

That gives you stable page boundaries as long as items are sorted by unique id.

You can see nextCursor behave correctly here:

const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];

function getPage(items, cursor, limit) {
  let start = 0;

  if (cursor !== null) {
    start = items.findIndex(item => item.id > cursor);
    if (start === -1) start = items.length;
  }

  const page = items.slice(start, start + limit);
  const nextCursor = page.length . page[page.length - 1].id : null;

  return { page, nextCursor };
}

const first = getPage(items, null, 2);
console.log(first.page.map(x => x.id), first.nextCursor);   // [1, 2] 2

const second = getPage(items, first.nextCursor, 2);
console.log(second.page.map(x => x.id), second.nextCursor); // [3, 4] 4

const third = getPage(items, second.nextCursor, 2);
console.log(third.page.map(x => x.id), third.nextCursor);   // [5] 5

const fourth = getPage(items, third.nextCursor, 2);
console.log(fourth.page.map(x => x.id), fourth.nextCursor); // [] null

If inserts can happen between requests, make sure the cursor key is immutable and unique.

WaffleFries

@Yoshiii the duplicate id warning is the part that bites later, because once nextCursor is just the last id.

Ellen

Thanks everyone, this helped me pin down the cursor boundary issue. The > change plus handling findIndex returning -1 fixed the duplicate page bug.

Sora