Exposing Pagination Metadata Without Breaking Clients
This page belongs to Response Envelopes & Metadata in the Query Patterns & Data Shaping Strategies reference. Adding cursors and counts to an endpoint that already has consumers is one of the most common evolution tasks in an API’s life, and one of the easiest to get subtly wrong — the failure mode is not an error but silently truncated results.
The decision trigger
| Change you want to make | Breaking? |
|---|---|
Add meta.next_cursor to an enveloped response |
No — additive |
Add meta to a bare array response |
Yes — top-level type changes |
Add an opt-in include_total parameter |
No — default behaviour unchanged |
Change the default limit from unlimited to 50 |
Yes — silently truncates results |
Lower the maximum limit from 1000 to 100 |
Yes for callers using more |
Add a Link header with rel="next" |
No — headers are ignored if unknown |
Change next_cursor from absent to null when exhausted |
Depends on client strictness |
| Reorder members in the JSON object | No — JSON objects are unordered |
Spec snippet: additive by construction
# openapi.yaml — every new member is optional and defaults to today's behaviour
components:
schemas:
PageMeta:
type: object
required: [has_more] # only the invariant is required
properties:
has_more: { type: boolean }
next_cursor: { type: [string, "null"] }
total_count: { type: [integer, "null"], description: Only with include_total=true. }
page_size: { type: integer, description: Echo of the applied limit. }
paths:
/invoices:
get:
parameters:
- name: limit
in: query
description: >
Page size. Omitting it preserves the historical behaviour of this
endpoint; it does NOT impose a default page size.
schema: { type: integer, minimum: 1, maximum: 500 }
- name: include_total
in: query
schema: { type: boolean, default: false }
The comment on limit encodes the crucial decision: an endpoint that historically returned everything must keep doing so until its unbounded behaviour is formally deprecated. A default page size introduced quietly is the most damaging “non-breaking” change in this whole area, because clients receive a well-formed 200 containing a fraction of the data and no indication anything is missing.
Step-by-step
Step 1 — Add metadata as new optional members
// list-invoices.ts — additive metadata, historical default preserved
const MAX_LIMIT = 500;
export async function listInvoices(req: Request, res: Response) {
const requested = req.query.limit ? Number(req.query.limit) : null;
const limit = requested ? Math.min(requested, MAX_LIMIT) : null; // null = unbounded, as before
const page = await queryInvoices({ cursor: req.query.cursor as string | undefined, limit });
const meta: Record<string, unknown> = {
has_more: page.nextCursor !== null,
next_cursor: page.nextCursor,
};
if (limit !== null) meta.page_size = limit;
if (req.query.include_total === "true") {
const { count, estimate } = await countInvoices(req.query);
meta.total_count = count;
meta.total_is_estimate = estimate;
}
res.json({ data: page.rows, meta }); // `data` unchanged for existing clients
}
Step 2 — Mirror links in the Link header
// link-header.ts — RFC 8288 relations alongside body metadata
export function setPageLinks(res: Response, base: string, nextCursor: string | null) {
const links: string[] = [`<${base}>; rel="self"`];
if (nextCursor) {
const url = new URL(base, "https://api.example.com");
url.searchParams.set("cursor", nextCursor);
links.push(`<${url.pathname}${url.search}>; rel="next"`);
}
res.setHeader("Link", links.join(", "));
}
Header links cost almost nothing and serve two audiences the body cannot: generic HTTP tooling that follows rel="next" automatically, and any endpoint still returning a bare array where the body has no room for metadata.
Step 3 — Prove the change is additive
# Spec-level: classify the diff before merging
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
breaking /specs/openapi.previous.yaml /specs/openapi.yaml --fail-on ERR
# Behaviour-level: replay recorded production requests against both builds
python3 - <<'PY'
import json, subprocess, sys
recorded = [json.loads(l) for l in open("recorded-requests.jsonl")]
mismatches = 0
for r in recorded:
old = subprocess.run(["curl", "-s", f"http://old.local{r['path']}"], capture_output=True).stdout
new = subprocess.run(["curl", "-s", f"http://new.local{r['path']}"], capture_output=True).stdout
o, n = json.loads(old), json.loads(new)
# The invariant: the items each caller sees must be unchanged.
o_items = o if isinstance(o, list) else o.get("data")
n_items = n if isinstance(n, list) else n.get("data")
if o_items != n_items:
print("MISMATCH", r["path"], file=sys.stderr)
mismatches += 1
print(f"{len(recorded)} replayed, {mismatches} mismatch(es)")
sys.exit(1 if mismatches else 0)
PY
The replay is what catches the truncation class of bug, because a spec diff sees a new optional parameter and correctly calls it additive — it cannot know that the default changed underneath. The general mechanics of classification are in Detecting Breaking Changes with oasdiff.
Step 4 — Retire unbounded responses deliberately
During stage two, warn in-band so the signal reaches the running integration rather than a mailing list:
{
"data": [ "… 4000 items …" ],
"meta": { "has_more": false },
"warnings": [
{
"code": "unbounded_collection_deprecated",
"message": "Requests without ?limit will be capped at 500 items from 2026-11-30. Adopt cursor pagination."
}
]
}
Two verification passes are needed because they catch different failures:
Standard compliance
| Element | Standard | Clause | Note |
|---|---|---|---|
Link relations |
RFC 8288 | §3 | next, prev, first, last, self |
| Tolerant reading | RFC 9110 | §16.3.1 | Unrecognised fields should be ignored |
| Deprecation signalling | RFC 8594 | §2, §3 | Announce the cap before enforcing it |
400 Bad Request |
RFC 9110 | §15.5.1 | For a limit above the maximum |
| JSON object ordering | RFC 8259 | §4 | Members are unordered — reordering is safe |
SDK / codegen downstream effect
export interface PageMeta {
has_more: boolean;
+ next_cursor?: string | null;
+ total_count?: number | null;
+ page_size?: number;
}
All three additions are optional properties, so previously generated clients still compile against the new type and newly generated ones gain access to the metadata. That is the whole test for an additive response change in a statically typed language: does the old code still compile against the new type. Publishing that new SDK version follows the semver rules in Semver Ranges for Generated SDK Clients.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Introducing a default page size retroactively | Keep unbounded behaviour until it is formally deprecated |
| Lowering the maximum limit without notice | Treat it as breaking; announce and measure first |
| Repurposing an existing field for a cursor | Add a new member; never change a field’s meaning |
| Relying on a spec diff alone | Replay recorded requests to catch default-value changes |
Putting links only in the Link header for enveloped responses |
Put them in the body and mirror in the header |
| Silently truncating instead of erroring | An explicit 400 is safer than a partial 200 |
FAQ
Is adding a field to a response a breaking change?
For the great majority of clients, no: RFC 9110 §16.3.1 expects unrecognised content to be ignored, and every mainstream JSON deserialiser skips unknown members by default. It is breaking for two populations — clients that validate responses against a schema in strict mode, and clients that deserialise into closed types with additionalProperties: false semantics. State tolerant reading as an expectation in your API documentation so the contract is explicit, and if you have a small, known consumer set, check them before relying on the assumption.
Should pagination links go in the body or the Link header?
If the response has an envelope, put them in the body and mirror them in the Link header. The body is where client developers look, where documentation renders them and where a typed SDK can expose them; the header is the standardised location that generic tooling already understands, and it is the only option for endpoints still returning a bare array. Emitting both costs a few dozen bytes and removes the argument entirely. What does not work is header-only links on an enveloped API — they get lost behind proxies, forgotten by client authors, and stripped by well-meaning middleware.
How do I introduce pagination on an endpoint that never had it?
In three stages, and never by adding a default. First add limit and cursor as optional parameters while an omitted limit continues to return everything — that is purely additive. Second, keep returning everything but attach an in-band warning and a Sunset date to unbounded requests, and use your access logs to identify exactly which callers are affected. Third, after the announced date, enforce the cap by rejecting unbounded requests with a 400 that names the parameter to use. The one thing to avoid at every stage is returning a truncated page with a 200, because a client cannot distinguish that from a genuinely small result set.
Related
- Response Envelopes & Metadata — up-link: the section defining what the envelope carries
- Query Patterns & Data Shaping Strategies — up-link: the parent reference for pagination, filtering and projection
- Envelope vs Top-Level Arrays in REST Responses — the prerequisite that makes these additions possible
- Offset vs Cursor Pagination — choosing what the cursor encodes
- Adding Required Request Fields Safely — the request-side counterpart to this evolution problem