You load two items, click “Next,” and see the last item again. In another session, an item disappears between pages. Suppose a small task list changes while you browse it: the code needs to say what “next” means when rows move.

Follow the filter, sort, and page together

Use a list ordered by createdAt descending, then by a unique numeric id descending. The teaching fixture uses small integers as times. Its six open items appear as 6, 5, 4, 3, 2, 1; items 5 and 4 share time 50. A separate done item, 0, has time 70.

Read this part Find this rule
selected Filter by status before taking a page
compare Newest time first; larger ID first when times tie
offsetPage / cursorPage Which rows count as the next page
Caller Save nextCursor, append accepted results, reset when the query changes

Start at the “Next” action and follow its arguments to the selection function. Reading only the final slice hides both the filter and the order that gave those positions meaning.

Watch the same offset point to different rows

This implementation skips a number of currently matching rows:

const compare = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const selected = (rows, status) =>
  rows.filter((row) => row.status === status).sort(compare);

function offsetPage(rows, offset, take = 2, status = 'open') {
  return selected(rows, status).slice(offset, offset + take);
}

The first page contains 6 and 5. Requesting the second page with offset = 2 produces different results after a single intervening change:

Change after page one Matching order now Second page What happened
None 6, 5, 4, 3, 2, 1 4, 3 Expected continuation
Insert 7 at time 70 7, 6, 5, 4, 3, 2, 1 5, 4 5 repeats
Delete 6 5, 4, 3, 2, 1 3, 2 4 is skipped

Each row starts again from the original fixture. The number of rows to skip stayed fixed while positions changed. Removing duplicate IDs in the interface can hide the repeat; it cannot recover the skipped item.

Compare the entire sort key

A cursor carries the last returned key. A timestamp-only predicate such as createdAt < cursor.createdAt loses item 4 after item 5, because both have time 50. Changing it to <= admits the previous item again.

Keep both values in (createdAt, id) and reuse the ordering comparison:

function cursorPage(rows, after = null, take = 2, status = 'open') {
  const items = selected(rows, status)
    .filter((row) => after === null || compare(row, after) > 0)
    .slice(0, take);
  const last = items.at(-1);
  return {
    items,
    nextCursor: last ? { createdAt: last.createdAt, id: last.id } : null,
  };
}

For this descending order, compare(row, after) > 0 means the row comes strictly after the saved position. Equal timestamps are resolved by id. The next page is 4, 3 after either the earlier insertion or deletion.

The nextCursor object copies the values instead of looking up the previous row again; deleting item 5 therefore still allows continuation from its saved key. An empty page returns null. This example may make one extra request to discover that the list has ended.

Run the changes as separate tests

Download pagination-reading.mjs and run it with Node.js 22 or later:

node --test pagination-reading.mjs

All ten tests pass. Three named reproduce: deliberately assert the offset duplicate, offset omission, and timestamp-only omission. The remaining seven check insertion, deletion before the cursor, deletion of its source row, full traversal through tied timestamps and an empty page, a newly inserted later row, filtering before limiting, and a filter reset.

The tests use fresh arrays and synchronous changes between calls. They need no service, account, timer, or package installation.

Read the caller before declaring the fix complete

Changing the filter to done while keeping the old cursor returns an empty page in this fixture: item 0 sorts before that cursor. Starting the changed query with after = null returns item 0. The caller should reset accumulated rows and the cursor together when the filter or sort changes.

Also inspect how nextCursor is stored and when it is consumed. Here, null after an empty response means stop; passing it straight back into the function would restart page one. Check for repeated submissions, out-of-order responses, and appending the same response twice. Those UI behaviors are outside this synchronous lab; tracing async selection changes provides a separate example for stale responses.

Decide how changes should appear to the reader

A cursor does not provide a snapshot. Insert item 8 at time 49 after reading page one: the next page becomes 4, 8. That new row sorts after the saved position, so it appears during the existing traversal. A new row before the cursor waits for a refresh.

The continuation checks assume unique IDs and unchanged sort keys on surviving items. Editing a key, changing status, or deleting an unread item needs an explicit product rule; a fixed export may require a separate snapshot strategy. This array lab tests no database isolation, index, transport, or cursor encoding. Its full filtering and sorting also make it unsuitable as a large-data performance design.

When reading your own implementation, keep the filter, comparison, saved key, and caller reset side by side. In Yenpo, open the related files and follow their references to check each step against the code.