Pagination Is a Contract You Sign in Public
The choice is not "offset or cursor." The choice is "which promise can your storage layer keep, to which caller, at what cost, and what do you write in the API documentation when the promise has to bend." This chapter is a contract review, run on the endpoints you have already shipped.
Key Takeaways
- Read the contract out loud. If you cannot finish the sentence "this endpoint promises…", the contract is not written.
- Audit the four questions on every paginated endpoint: what does the endpoint promise, what can the storage layer keep, what does the caller assume, and where do the two diverge.
- The mitigation is rarely a library upgrade. It is a sentence in the API documentation, or a second endpoint with a different contract for a different caller.
- The cost of pagination is paid in the storage engine. Treat it as a database problem the day the endpoint ships, not the day the page goes 11 seconds.
---
Open a feed. Scroll for a while. Click Next. The page that loads is not the page you expected. A row you already read reappears at the top. Another row you saw five minutes ago is gone.
You have read this paragraph before, at the start of this series. You will read it again, because the page is the same page, and the contract is the same contract, and the question I want to leave you with is the same question: *what did the endpoint promise, in plain English, before the user clicked?*
The series has argued that pagination is a contract, that the contract comes in two shapes, and that each shape imposes a cost the storage layer pays whether or not the caller notices. This chapter is the audit. Not a summary — a contract review. If the four-chapter series is a piece of work, this is the part where the work is done in your codebase, not in the article.
---
The audit
The audit is four questions. Run them on every paginated endpoint in the system you ship next week. The questions are not clever. The point is that you can answer them in one sentence each, and if you cannot, the contract is the part of the system that needs the most attention.
stateDiagram-v2
[*] --> Promise: 1. What does the<br/>endpoint promise?
Promise --> Cost: 2. What does the<br/>storage layer keep?
Cost --> Assumption: 3. What does the<br/>caller assume?
Assumption --> Diverge: 4. Where do they<br/>diverge?
Diverge --> Action: Fix the gap:<br/>rewrite the contract<br/>or rewrite the code
Action --> [*]
Each transition is a question. Each question is answerable in one sentence. The audit is the four sentences, in order, on a sticky note, next to the API definition. The sticky note is the contract. The code is whatever the contract says it is.
The four questions, with the answers that matter
1. What does the endpoint promise?
The honest answer is a sentence that names a *promise*, not a *parameter*. Not "the endpoint takes ?page and ?size" — that is a parameter list. The promise is "this endpoint returns rows 21 through 40 of the current ordered set, in the order they appear now." Or it is "this endpoint returns the 20 rows that sort strictly after the cursor, in the same order as the previous page." The first promise is positional. The second is sequential. The two promises are not the same, and the endpoint that promises both is the endpoint that will fail in production.
If you cannot finish the sentence, the contract is not written. If the contract is not written, every cache, every frontend, every mobile client is filling in the blanks with whatever they assumed, and the assumed contract is the one that will be wrong.
2. What can the storage layer keep?
The honest answer is the cost model the storage layer can actually deliver, not the cost model the API documentation implies. The cost model is "the first page is an index range scan, the Nth page is a scan that reads 20 × N rows, the total count is a maintained counter that lags by up to 30 seconds." Or it is "every page is a constant-time index range walk from the cursor, the leading edge of the index is a write-amplification target, the total count is not available."
The contract and the cost model have to match. If the contract promises positional stability, the cost model has to be able to deliver it without scanning the world. If the contract promises sequential stability, the cost model has to be able to deliver it without concentrating all writes on one index range. The cases where they do not match are the cases where the storage layer is being asked to honor a promise it cannot keep, and the storage layer is honoring the promise by lying.
3. What does the caller assume?
The honest answer is the caller's mental model, which is almost always "I am scrolling forward, and I have not seen any of these rows before." The caller is not thinking about index ranges, or cursors, or keyset predicates. The caller is thinking about a feed.
This is the question that exposes the bug. The caller's assumption is sequential ("I have not seen these rows"). The endpoint's promise may be positional ("here are rows 21–40"). The storage layer's cost model is what it is. The bug is the gap between the caller's assumption and the endpoint's promise, and the gap is invisible to the storage layer, and the storage layer is the one paying the bill.
4. Where do they diverge?
This is the question that produces the work. The divergence is the bug. The bug is not a typo, not a missing index, not a race condition. The bug is a sentence in the API definition that the storage layer cannot keep, and the caller is reading the sentence and inferring a different sentence, and the user's experience is the gap.
The fix is one of three:
- Rewrite the contract so the endpoint's promise matches what the storage layer can keep. Cursor pagination for sequential feeds. Offset pagination for admin tables. A second endpoint with a different contract when both intents are real.
- Rewrite the cost model so the storage layer can keep the contract the endpoint is already promising. Add the index. Maintain the counter. Decouple the read path. Pay the cost the contract was always going to impose.
- Rewrite the documentation so the caller's mental model matches the contract the endpoint is honoring. "This endpoint may return rows the caller has already seen, and may omit rows the caller has not seen, under concurrent writes." This is the honest version of "this endpoint is eventually consistent," and the honest version is the one the caller can build against.
The first option is usually the right one. The second is usually the expensive one. The third is the one teams pick when they do not want to do the first two, and the third is the one that produces the production incidents.
---
What I have stopped believing
I started this series believing I was going to write a guide. I ended up writing a contract review. The shift happened somewhere between chapter one and chapter two, in the moment I realized that every pagination bug I had ever debugged was a contract bug wearing a UI costume.
I no longer believe that pagination is a feature. The cost is too large, the dependency graph is too deep, and the failure modes are too remote from the code that introduces them. A "Next" button is not a feature. It is a promise about a storage engine, signed in public, inherited by every cache and every client that touches the endpoint.
I no longer believe that there is a "right" pagination scheme. There is a right contract for a given user intent, and a different right contract for a different user intent, and the engineering work is matching the contract to the intent and matching the storage layer to the contract. The matching is not free, and the matching is not a library.
I no longer believe that the cost of pagination is paid by the user who fetches the page. The cost is paid by the storage layer, on behalf of every page the contract is willing to serve, whether or not the user fetches it. The user who fetches page 1 in 4 ms is sharing a database with the depth charge that fires at page 50,000, and the depth charge is in the plan, not in the request.
I also no longer believe that the two librarians were offering equally good answers. They were offering *different* answers, and the answer the caller wanted depended on what the caller was doing. The librarian who hands you a card is not better than the librarian who hands you a shelf. The librarian who hands you a shelf is not better than the librarian who hands you a card. They are different librarians, with different promises, for different callers.
The mistake is calling them both "pagination" and shipping the wrong one.
---
The audit, run on the next endpoint
The next time you add a paginated endpoint, before the code review lands, write the four sentences:
1. *This endpoint promises to return…* 2. *The storage layer can keep that promise by…* 3. *The caller will assume…* 4. *The gap between (1) and (2), or between (2) and (3), is…*
If you can write all four sentences, the endpoint is ready to ship. If you cannot write the fourth, the endpoint is not ready, and the missing sentence is the bug you will be paged about.
This is the part of the article I keep coming back to. Not the EXPLAIN plan, not the hot key, not the row that moved. The four sentences. The four sentences are the contract. The four sentences are the audit. The four sentences are the part that fits on a sticky note, next to the API definition, in the code that is shipping on Tuesday.
You open a feed. You scroll for a while. You click Next. The page that loads is the page the contract promised.
It is correct. It is what the contract said. The contract was honest.
The contract is what you wrote.