DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

Pagination Is a Consistency Contract

Page numbers feel harmless. They are familiar, easy to render, and easy to explain. But on a mutable work queue, they can lie.

Imagine a reviewer reading a newest-first queue. They load the first 25 items. Before they request the next page, a new item arrives at the top. An offset such as skip 25 now starts one row later than it would have a moment ago. The last row from page one may appear again, and the row displaced by the insert may never appear in that walk.

Nothing crashed. No error was logged. The interface simply presented an inconsistent view of the work.

The engineering lesson is that pagination over changing data is a consistency contract, not a decorative control beneath a table.

Begin with the mutation model

The right pagination strategy depends on how the collection changes.

For an append-mostly queue ordered newest first, new work normally arrives above the operator's current position. Other fields, such as workflow status, may change while the operator is reviewing an item. Those two facts suggest a useful boundary: continue from something fixed when the item entered the queue, not from a mutable field and not from the current numeric position.

A submission timestamp can provide that boundary if it is frozen when the item is created. Status usually cannot; ordering by status lets a decision move rows between pages while someone is walking the list.

The principle is broader than timestamps: choose an ordering key whose meaning remains stable for the duration of a walk.

Make the server own the continuation

A client should not reconstruct position from a page number, a visible row, or a guessed offset. The server should return an opaque continuation and accept it unchanged on the next request.

For a newest-first queue, a continuation can describe the last timestamp boundary and how many rows at that exact instant have already been emitted. The next query includes rows at or below the boundary, skips only the already-seen ties at that boundary, and takes one extra row to determine whether another page exists.

Why count ties instead of comparing an identifier? Identifier ordering is not always portable across database providers. Let the query provider apply its established ordering, and record only how far through the boundary group the walk has progressed.

Opaque does not mean unchecked. Reject blank, malformed, oversized, or unsupported continuations. A bad continuation should not silently restart at the top, because a restart looks like valid data with unexplained duplicates.

Bind position to query context

A continuation names a position in one specific ordered result set. Change the filter and that position may no longer mean anything.

Suppose page one was filtered to one workflow cycle, then the same continuation was replayed with the filter removed. Honouring it could skip rows that were never part of the original walk. The continuation should therefore carry enough context to prove it belongs to the active query. If the context differs, refuse the request and let the client restart explicitly.

The same discipline applies to page size. Put a small upper bound in the contract and reject values outside it. Quietly shrinking an oversized request hides caller mistakes; accepting it defeats the point of paging.

Bounding the first query is only half the work. If each row needs evidence, comments, or another related record, fetch those details only for identifiers on the current page. Otherwise an apparently paged endpoint can still perform an unbounded secondary read.

Preserve the operator's place

Continuation cursors naturally move forward. A usable review screen also needs Back and refresh.

The client can keep a short trail of the server-provided continuations used to open each visited page. Previous then replays the cursor that originally opened the prior page. The client is not interpreting the cursor; it is remembering the path.

After a reviewer changes an item, refresh the page they are currently viewing. Returning to page one after every decision is technically simple and operationally punishing. It also encourages people to work around the interface rather than trust it.

There is one awkward edge case: a later page may become empty before it is refreshed. Do not render a global empty-state message that implies the entire queue has vanished. Reset to the first page and show the current truth from a valid starting position.

Test the walk, not only the query

Happy-path tests that assert “25 rows returned” are not enough. The important properties live between requests.

Useful executable checks include:

  • a new item arriving above the boundary neither duplicates nor hides an existing row;
  • a workflow status change does not alter page membership;
  • several items sharing one timestamp are neither repeated nor lost;
  • a continuation cannot be replayed with a different filter;
  • malformed continuations and invalid page sizes are refused;
  • forward and backward navigation use the exact server continuations;
  • a write refreshes the active page; and
  • an empty later page recovers without claiming the whole queue is empty.

These tests describe the contract more clearly than the word “paged” ever could.

The trade-off is explicit complexity

Cursor paging asks more of both sides. The server must define and validate a stable continuation. The client must retain a small history. Ties and disappearing pages need deliberate behaviour. Troubleshooting is less intuitive than looking at page=3 in a URL.

In return, database work stays bounded and a reviewer can walk a changing queue without silent duplicates or gaps. That is a worthwhile trade whenever missing one item matters more than keeping the paging code superficially simple.

Before choosing offsets for a mutable list, simulate one insert and one update between consecutive requests. If the same walk no longer produces the same set of rows, the pager needs a stronger contract.

Top comments (0)