# How to negotiate page size between an API and its clients

> Clamp an over-large page size to your ceiling rather than rejecting it, say in the response what was served, and refuse only values that are not sizes.

Source: https://voxgig.com/howto/negotiate-page-size-with-clients

- Audience: api-producer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## Short answer

Pick a default that suits a first request and a maximum that bounds one query. Clamp anything above the maximum and serve it, because a caller asking for more rows made a reasonable request your API cannot fill. Refuse values that are not positive integers. Return the size served and the ceiling in the response so a client can adapt without reading documentation.

---
## You will need

An endpoint that returns a list, and a number for how much work one request may ask of your database.
That number is a capacity decision rather than a style one, so measure the query at a few sizes
before choosing it. [GitHub caps its list endpoints at 100 per page](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api),
and [Stripe caps its at 100 as well](https://docs.stripe.com/api/pagination). That is a useful
starting point for a JSON collection with a handful of fields per row.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Clamp and advertise](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api) | Public APIs with clients you cannot coordinate a change with | A client that ignores the response believes it got what it asked for | Silently serving less would corrupt the caller's logic |
| [No maximum at all](https://www.postgresql.org/docs/current/queries-limit.html) | Internal endpoints over small, bounded collections | One caller can ask for the whole table and take the database with them | The collection grows, or callers are outside your team |
| [Reject over the maximum](https://www.rfc-editor.org/rfc/rfc9457) | APIs whose callers should notice and fix their request | An export that worked yesterday fails today when you lower the ceiling | Callers are scripts nobody is watching |
| [Server-chosen size only](https://docs.stripe.com/api/pagination) | Feeds where you tune the size against load | Callers with different needs all get one compromise, and none can tune it | A caller has a good reason to want a different size |

Clamping and rejecting differ in who absorbs the mismatch. Clamping keeps the export running at a
size you can serve, and it relies on the client noticing what arrived. Rejecting makes the mismatch
loud and stops a job that could have completed. For an unattended client the first is better,
because a slower export beats no export.

## Resolve the size in one place

Three outcomes, and the third is the only one that is an error.

```ts title="pagesize.mjs"
export function resolvePageSize(raw, limits = PAGE_SIZE) {
  if (raw === null || raw === undefined || raw === '') {
    return { size: limits.default, applied: 'default' }
  }
  if (!/^\d+$/.test(String(raw).trim())) {
    return { error: `per_page must be a positive integer, received ${JSON.stringify(raw)}` }
  }
  const asked = Number(raw)
  if (asked === 0) return { error: 'per_page must be at least 1' }
  if (asked > limits.max) return { size: limits.max, applied: 'clamped', asked }
  return { size: asked, applied: 'as asked' }
}
```

The split between clamping and refusing is the judgement in the function. Asking for 5000 is a
request your API cannot fill, and it has an obvious best answer: serve the most you can. Asking for
`lots` is not a size at all, and guessing a number for it hides a client bug that will surface later
as missing rows.

Returning a structure rather than a number lets the caller of this function report what happened.
The endpoint uses `applied` to decide which headers to set, and the same value is what a log line
needs when somebody asks why an export is slower than the client expected.

## Say what was served

A client can only adapt to a ceiling it is told about. Two response headers carry it.

```ts title="server.mjs"
    // The response says what was served and what the ceiling is, so a client
    // can adjust without reading the documentation or guessing from a count.
    res.writeHead(200, {
      'content-type': 'application/json',
      'x-page-size': String(resolved.size),
      'x-page-size-max': String(PAGE_SIZE.max),
    })
```

Echoing the applied size in the body as well suits an envelope response, and a bare array has
nowhere to put it. Either way, a client that compares what it asked for against what it received can
stop asking for more than it will get.

Pick one parameter name and keep it across every collection you serve. `per_page`, `limit` and
`page_size` are all in wide use, and the cost of mixing them is paid by every client author who has
to remember which endpoint takes which. A caller who guesses wrong usually gets the default rather than an
error. The mistake then shows up as a slow export rather than as anything a test would catch.

## Check it worked

Four requests: no size, a size inside the ceiling, one far beyond it, and one that is not a number.

```bash
node demo.mjs
```

```text output
per_page=(omitted) 200  served 25, ceiling 100
per_page=10        200  served 10, ceiling 100
per_page=5000      200  served 100, ceiling 100
per_page=lots      400  per_page must be a positive integer, received "lots"
```

The third line is the one that keeps the export alive: the caller asked for fifty times the ceiling
and got a working page rather than an error. The fourth is refused, because there is no sensible
number to substitute for `lots`.

```bash
node --test pagesize.test.mjs
```

```text output
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 110.621526
```

## When it goes wrong

A client pages by counting. Code that asks for 100 rows and stops when it receives fewer than 100
treats a clamped page as the last page. A clamped request for 5000 then looks like an end that has
not arrived.
Advertise the next page explicitly with a link or a cursor, and the count stops being a stopping
condition anybody has to infer.

The second failure is a ceiling that is uniform across endpoints with very different costs. A hundred rows from a flat table and a hundred rows that each expand a
nested relation are not the same query. One ceiling for both is either too low for the cheap
endpoint or too high for the expensive one. Set the maximum per endpoint, and keep the parameter name and behavior the same.

## When not to do this

Do not clamp silently on an endpoint whose callers need exactly what they asked for. A caller
building fixed-size batches for a downstream system is relying on the number, so refusing is kinder
than serving a different one.

Do not raise the maximum because one client asked. The ceiling is a statement about your capacity,
and a client with a genuine bulk need is asking for a different endpoint rather than a bigger page.

Do not accept a page size in a header as well as a query parameter. Two ways to say the same thing
means deciding which wins, documenting it, and a caller who sets both and gets the other one.

Do not let the default drift up over time. A default that suits a first exploratory request is
usually small, and raising it to make an internal job faster changes the cost of every call made by
everybody else.

## Related how-tos

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Advertise next and previous pages with Link headers](/howto/link-headers-for-pagination-rfc-8288)

## Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed,
against a local server holding 500 rows.