Dev Learning

API design — pagination, versioning, errors

The contract you cannot change later, so get the three boring parts right now.

1

What problem does these API rules solve?

An API is a promise to people you will never meet. Once someone depends on it, you cannot quietly change it, and every mistake you made on day one you get to carry for years.

Three of those mistakes come up in most API design discussions: how you page through a long list, how you change the shape of a response without breaking anyone, and what you say when something goes wrong.

2

What these API rules costs you

What it costs you
Doing this well costs you flexibility up front. A versioned API means running two versions at once for a while. Cursor pagination means clients cannot jump to page 500. Careful error contracts mean writing down failure cases instead of letting an exception leak out. All of that is work you do before anyone has complained.

Every concept in here states a cost. A technique with no price is a technique you have not understood yet — and naming the cost out loud is most of what separates a senior answer from a junior one.

3

When to reach for these API rules, and when not to

Use it when

  • Any API other teams or other companies will call. This is a default.
  • Any list endpoint that can grow. Which is nearly all of them.
  • Any endpoint a mobile app calls, because old app versions live on phones for years.

Do not, when

  • A private endpoint with one caller that you deploy at the same time. Then a simple shape and a simple change is fine.
  • Do not build a version scheme before you have a single consumer. Ship v1 and mean it.
4

How these API rules actually works

A
Offset — ?page=500&size=20
  • —The database walks past 10,000 rows and throws them away to give you 20.
  • —Cost grows with how deep you page. Page 1 is instant, page 500 is a scan.
  • —Rows shift while the user pages, so they see duplicates or skip items.
  • —Lets the user jump to any page, which is sometimes a real product need.
B
Cursor — ?after=eyJpZCI6...
  • —The cursor encodes where you stopped, so the next page is an index seek.
  • —Page 500 costs the same as page 1.
  • —Stable while data changes underneath, because it anchors on a real row.
  • —No jumping to an arbitrary page, and no total count without a separate query.
Which one: Use a cursor by default, and only offer offset paging where the product needs page numbers and the list is small. If someone asks for both, cursor is the API and page numbers are a UI built on top of a capped result.
The pagination choice that decides whether your list endpoint survives growth.

Pagination. An endpoint that returns "all" of something is a bug waiting for your biggest customer. Always page. A cursor is an opaque string that encodes the sort key of the last row you returned, so the next query is "give me rows after this point", which an index answers directly. Offset paging asks the database to count past everything before it, so it gets slower the further you go. Across a sharded store it gets much worse, because every shard has to produce all those rows for the merge. Make the cursor opaque (base64-encoded, say), so clients treat it as a token instead of building one by hand, and you can change what is inside it later.

Versioning. You will need to change a response shape. There are three ways to survive it. Put the version in the URL path (/v2/orders): ugly, obvious, and by far the easiest to reason about and to route. Put it in a header: cleaner URLs, but harder to test with curl, which matters more than it sounds. Or do not version at all, and only ever make additive changes: new fields are fine, removing or renaming a field is not. That last approach is often the most practical, with a version bump reserved for a genuine break.

Whichever you pick, say the two rules that keep you safe. Adding a field is safe. Removing or renaming one is not. And clients must ignore fields they do not recognise, which you have to document, or client authors will not know to do it.

Errors. Return the right HTTP status, and a body that a machine can act on and a human can read: a stable machine-readable code, a message for the developer, and where relevant which field was wrong. The status code carries the category: 400 you sent something wrong, 401 who are you, 403 I know who you are and no, 404 not here, 409 that conflicts with the current state, 422 the shape was right but the values were not, 429 slow down, 5xx my fault. The part people get wrong is retryability. A client needs to know whether to try again, so 429 and 503 should carry Retry-After, and other 4xx codes mean "do not retry this unchanged".

Idempotency belongs in the API contract, not only in the implementation. Any endpoint that creates something or moves money should accept an Idempotency-Key header, and you should say in the docs how long you remember it. That header is what lets a mobile client on a bad connection retry safely.

Two more that come up. Filtering and sorting should be a small fixed set of allowed fields, not arbitrary user input turned into a query, or you have handed people a way to run expensive scans. And partial responses (letting a client ask for only the fields it needs) save real bandwidth for mobile, at the cost of a caching layer that now has to key on the field list too.

Common trap
  • A list endpoint with no pagination at all. It works until your biggest customer arrives.
  • Offset paging on a sharded store, which gets dramatically worse as shards grow.
  • Returning 200 with an error inside the body. Now nothing can retry correctly.
  • Removing a field because "nobody uses it". Someone often does, and you will hear about it from them.
Say this
"List endpoints are cursor-paginated, with the cursor opaque so I can change what is inside it. Changes are additive by default, and I only version when the meaning of a field changes. Errors carry a stable code plus Retry-After on 429 and 503, so clients know what is safe to retry. Cost: no jumping to page 500, and no total count without a separate query."
5

The these API rules follow-up an interviewer will ask

"Your mobile app is on version 3 but half your users are still on version 1. How do you ship a breaking change?"

What a strong answer sounds like

First, I try not to ship it as a break. Old app versions live on phones for years, some of those users will never update, and you cannot force them. So the default move is expand and contract: add the new field alongside the old one, populate both, ship the new app version reading the new field, watch usage of the old field decay, and only remove it when the number is small enough to accept the loss. That can take a year, and it is fine. When the change cannot be additive (the meaning of a field has changed, not just its name), I version the endpoint, run both, and put a translation layer at the gateway so the old shape is produced from the new internal model rather than by maintaining two code paths. The costs I would name: two shapes to test, a translation layer that is easy to forget when adding features, and a deprecation date that I have to enforce with monitoring on old-version traffic, or v1 lives forever.

6

Your turn

60-second self-check

A user is on page 500 of a list, sorted by newest first, and new items keep arriving. What goes wrong with offset paging, and how does a cursor fix it?

Stuck? Take one hint at a time0 of 2 used
0 / 25 characters
7

Learn it from the source

This page gets you interview-ready. These take you to where the idea is actually specified or measured — read them when you want the real depth.

Where you will use this