How-to › Move data in and out

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.

Audience
API producer
Level
beginner
Topic
Paginate collections
Languages
TypeScript and JavaScript
Verified

Read first: Choose a pagination style for a list endpoint

A client asks for 5000 rows per page to finish an export faster. Your endpoint serves it, the query takes eleven seconds, and the connection times out at ten. The client retries with the same size, and the collection is never exported. Somewhere between their request and your capacity, one number needed a ceiling and nobody set one.

What you get

You will end up with a page size that has a default, a ceiling, and a response that says which was applied. This is for you if callers can ask your list endpoint for as many rows as they like.

Short answer

Pick a default that suits a first request and a maximum that bounds one query. Clamp anything over 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, and Stripe caps its at 100 as well. That is a useful starting point for a JSON collection with a handful of fields per row.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Clamp and advertisePublic APIs with clients you cannot coordinate a change withA client that ignores the response believes it got what it asked forSilently serving less would corrupt the caller’s logic
No maximum at allInternal endpoints over small, bounded collectionsOne caller can ask for the whole table and take the database with themThe collection grows, or callers are outside your team
Reject over the maximumAPIs whose callers should notice and fix their requestAn export that worked yesterday fails today when you lower the ceilingCallers are scripts nobody is watching
Server-chosen size onlyFeeds where you tune the size against loadCallers with different needs all get one compromise, and none can tune itA 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.

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.

    // 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.

node demo.mjs
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.

node --test pagesize.test.mjs
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.

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.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.