Pagination is often introduced as a performance improvement: fetch fewer rows, render less markup, and keep response sizes predictable. That is only half the job.
For an operational queue that changes while people work, pagination also defines what it means to continue reading. If that contract is weak, the database can answer every query correctly while the person using the screen still sees duplicates or misses work.
The important design question is not merely, “How many rows should this query return?” It is, “What does the next request mean after the underlying set has changed?”
Why offset paging drifts
Consider a queue ordered newest first. A reviewer asks for the first twenty-five rows. The next request uses an offset of twenty-five.
Between those requests, a new row arrives at the top. Every earlier row has moved down one position. The second query now begins with the final row from page one, so the reviewer sees a duplicate. More importantly, one unseen row has been pushed beyond the requested range.
This is not an exotic race. Append-mostly queues invite it because adding work is the ordinary operation. Offset paging treats positions as stable even though insertion changes every position below it.
Mutable sorting creates a similar problem. If the queue is ordered by status or last-updated time, an operator’s decision can move a row while the walk is in progress. A continuation based on that value inherits the instability of the workflow.
Continue from an immutable boundary
A stronger contract says: continue after the boundary returned by the previous read.
For a newest-first queue, that boundary can be an immutable creation or submission timestamp. New rows arriving above the boundary are not part of the current walk. They will appear when the reviewer starts again from the top, but they do not shift the meaning of “next.”
Equal timestamps need deliberate handling. Adding an identifier as a tie-breaker becomes fragile when data providers sort that type differently.
Another approach is to carry the boundary timestamp plus the number of rows already consumed at that exact timestamp. The database remains responsible for its native ordering within the tie, while the continuation records how far through that tie the walk has progressed. The detail matters less than the invariant: ties must neither duplicate nor hide rows.
Make the continuation part of the contract
An opaque cursor should not be a loosely encoded page number. It is protocol state, so treat it that way.
Useful safeguards include:
- a version, so the format can evolve deliberately;
- a strict length limit and parse validation;
- the immutable boundary and tie position;
- the active filter identity; and
- sensible bounds on any stored count.
Binding the cursor to its filter is especially important. A position in “all open items” does not mean the same thing in “items for one category.” Reusing the cursor after changing filters can silently skip work. Rejecting that request is clearer than pretending the cursor is still meaningful.
Likewise, a malformed continuation should fail explicitly. Silently dropping it and returning the first page converts a bad request into a plausible-looking duplicate page. Operational software benefits from visible invalid states.
Keep every downstream read bounded
Paging the primary table is not enough if enrichment still loads data for the entire result set.
A common pattern reads the page, extracts its identifiers, and then fetches related summaries in a second query. That identifier list must come from the bounded page. Otherwise the visible query is paged while a hidden IN clause continues growing with the queue.
Fetching one extra primary row is a simple way to determine whether a continuation is needed. Return the requested page size, and issue a next cursor only when the extra row exists. The presence of that cursor—not whether the current page happens to be full—becomes the authoritative “has more” signal.
This also avoids an exact count on every read. For many live queues, “Page 3” plus Previous and Next is the more honest interface.
Carry the contract into the Blazor UI
Server-side continuation paging should not be wrapped in a client component that first downloads the entire queue. The UI has to follow the same contract.
A practical Blazor page can keep a small trail containing the cursor used to open each visited page. Next stores the returned cursor. Previous reuses the earlier cursor to re-read that logical page. After an operator records a decision, refresh the current page rather than throwing them back to the beginning.
Page changes should also clear any pending confirmation tied to a row that is no longer visible. That is a small state-management detail with a large clarity benefit: the user should never be asked to confirm an action on an item they cannot see.
Test the movement, not only the shape
Happy-path tests that assert “twenty-five rows returned” do not prove a paging contract.
The valuable cases exercise movement:
- insert a newer row after reading page one, then verify page two has no gap or duplicate;
- create several rows with the same boundary timestamp;
- reject zero, negative, and excessive page sizes;
- reject malformed, oversized, and filter-mismatched cursors;
- verify related-data queries receive only current-page identifiers;
- navigate Next and Previous in the component; and
- perform an action and confirm the current page is refreshed.
These tests describe the promise a reviewer experiences, not merely an implementation detail.
The honest trade-off
Continuation paging is more work than offset paging. It needs a stable sort, a cursor format, versioning, filter binding, careful tie semantics, and focused tests. It also makes random access and exact page counts less convenient.
That cost is justified when the list is both live and operationally important. Bounded queries protect the system under bursty load; stable continuation protects the human workflow under concurrent change.
Takeaway: choose pagination from the mutation pattern, not from the table widget. If ordinary inserts can change what “next” means, the continuation itself deserves first-class design.
Top comments (0)