REST in production
Chapter 04 gave the blog API a working set of endpoints. This chapter adds what a real deployment needs: sending a long list in pieces, not sending the same body twice, changing the API without breaking old clients, and retrying a payment that timed out.
Pagination: why a list is sent in pieces
Your blog did well and the posts table now holds a million rows. GET /posts cannot return all of them.
A library lends you twenty books, not the whole shelf
Returning a million rows in one response costs three parties at once: the database reads and serializes everything, the network carries megabytes, and the client has to hold it all in memory. So every serious list endpoint uses pagination: it returns a small slice, and the client asks for the next one when it needs it.
The interesting part is the word “next”. Where exactly should the next slice start? There are two answers, and they behave very differently: offset counts rows, and a cursor points at the last record you received.
The two costs of offset pagination
First, deep pages get slow. Skipping N rows still means walking over N rows, so the cost grows with the page number. Second, the window moves. If rows are inserted or deleted before the reader asks for the next page, the positions shift: an insert makes one item appear twice, and a delete makes one item disappear without ever being shown. Chapter 04 mentioned this trade-off; the stepper above is where you can watch it happen.
Cursor pagination is not free either. There is no page number and no total count you can rely on, and the sort order has to be stable: sort by a unique column, or add the id as a tiebreaker, otherwise two records with the same timestamp can still be skipped or repeated.
Filtering, sorting, and choosing fields
The path says which resource. Query parameters say how you want it. Here is one long URL, taken apart one piece at a time.
Pagination alone is not enough. A client may want only published posts, in newest-first order, with just a few fields so the response stays small on a phone. All of that goes after the ?, and the resource itself does not change. Click each part of the URL below.
Filtering narrows the collection: only published posts. Using the field name as the parameter name is the most common convention. Add another condition with &, for example &author=42. Conditions written this way are combined with “and”.
Why parameters instead of one endpoint per view?
“Published posts”, “newest posts”, and “posts with only a title” are the same collection seen in different ways. If each view gets its own endpoint, the count multiplies: published, newest, published and newest, published and newest with only a title, and so on. Parameters combine, so five of them cover every combination without adding a single endpoint.
Versioning: changing an API that already has users
Once an API has clients, every change you make is a change they did not ask for.
Suppose you rename the response field author to writer. Every app already installed on a phone still reads author, and starts showing an empty name. An API is a contract. You can change it, but the old and the new form have to exist side by side long enough for clients to move. Three ways are used to say which form you want:
| Strategy | What it looks like | Used by | Trade-off |
|---|---|---|---|
| Version in the URL | /v1/posts | Most public APIs; Twilio uses a date: /2010-04-01/ | Easy to see, easy to test in a browser, easy to route and cache. The objection is that the same resource now has a different URL in each version. |
| Version in a header | X-GitHub-Api-Version: 2022-11-28 | GitHub (dated versions); Stripe (Stripe-Version) | The URL stays the same for every version. In exchange you cannot try it from the address bar, and any shared cache must be told to vary on that header or it will serve one version to everybody. |
| Version in the media type | Accept: application/vnd.github.v3+json | GitHub, previously | The closest fit to how HTTP is defined: the version belongs to the representation, not to the resource. It is also the hardest to use, because clients and tools have to set Accept correctly every time. |
No option is clearly best; the cheapest version is the one you never ship
All three work. URL versioning is the most common because it is the easiest to read, test, and explain. Header versioning keeps URLs stable. Media-type versioning matches the specification most closely. Pick one, apply it consistently, and write down how long an old version will keep working.
The advice given most often, though, is not about which strategy to pick: avoid breaking changes instead of versioning often. Every version you keep alive is another code path to test, document, and fix. Adding optional fields and new endpoints costs nothing and needs no new version.
How GitHub changed its mind
GitHub used media-type versioning for years (vnd.github.v3+json). It fits the specification well, but users forgot the header and tools set it wrongly, so it was expensive to support. In 2022 GitHub moved to a dated request header, X-GitHub-Api-Version: 2022-11-28, and stated that each version stays supported for at least 24 months. The neater design lost to the one that was easier to use correctly.
Removing a field (the value becomes undefined), changing its type (parsing fails), changing what it means (the data is quietly wrong, which is the worst case), or changing what a URL or a status code stands for.
Adding an optional response field, adding an endpoint, or adding an optional query parameter. A client that does not know a JSON key simply ignores it. This is why an API lasts longer when it only adds.
Caching: do not send the same body twice
The fastest request is the one the client never sends. The second fastest is answered with headers only.
A cache is a stored copy of a response. Two separate questions decide what it can do with that copy. Freshness: may the copy be reused right now without contacting the server? Validation: if it may not, has the resource actually changed? Cache-Control answers the first question. Validators such as ETag answer the second.
max-age=3600: for the next hour a client may reuse this copy without sending a request at all. private restricts that to the browser that asked for it. Cache-Control is the modern control; Expires is the older header that does the same job with an absolute date, and Cache-Control wins when both are present.| Directive | What it tells a cache |
|---|---|
max-age=3600 | Reuse this response for 3600 seconds without asking the server. |
no-cache | Store it, but revalidate with the server before every reuse. |
no-store | Do not keep a copy at all. This is the one for private data. |
private | Only the browser that made the request may store it. A shared cache must not. |
public | A shared cache may store it, even when the request carried an Authorization header. |
must-revalidate | Once max-age has passed, do not serve the copy anyway; revalidate first. |
no-cache does not mean do not store
This is the pair people get wrong most often. no-cache means store the response, but check with the server before using it again. If nothing changed, that check costs one small request and no body. no-store means do not write it anywhere — not to memory, not to disk. Use no-store for bank statements and password reset pages. Use no-cache when the data may be reused but must never be shown stale.
When a copy is no longer fresh, the client does not have to download it again. It can ask a conditional question: I have this version — has it changed? If the answer is no, the server replies 304 Not Modified and sends no body. Watch the six steps:
ETag + If-None-MatchThe server sends an opaque identifier for the current version. The client sends it back in If-None-Match. A tag written W/"abc" is weak: it promises the content is equivalent, not byte for byte identical. A weak tag is enough to answer 304, but it cannot be used to request a byte range, because a range needs an exact match.
Last-Modified + If-Modified-SinceThe older pair, based on a timestamp. It is simpler, but its resolution is one second, so two changes within the same second look identical. Prefer ETag when the server can compute one; send both if you like, and the client will use If-None-Match first.
Private cache, shared cache, and Vary
A private cache belongs to one user: the cache inside a browser. A shared cache serves many users: a CDN node or a company proxy. That is why the private directive exists. A response containing one user's profile must not sit in a CDN where the next visitor could be handed it.
When the response depends on a request header, say so with Vary. A response compressed because the request said Accept-Encoding: gzip needs Vary: Accept-Encoding; a cache that ignores it can hand a gzip body to a client that cannot decompress it. The same applies to a version header: without Vary, one client's version is served to everyone. Note that Vary also multiplies the stored copies, so listing many headers lowers the hit rate.
REST gets most of this from HTTP itself
A GET and its URL already identify what is being asked for, so the URL works as a cache key. Every device along the path — the browser, a proxy, a CDN — understands the same rules, and none of them need to know anything about your application. Chapter 10 shows the other side of this: GraphQL usually sends every operation as a POST to a single endpoint, so HTTP caches cannot tell two different queries apart and the caching has to be rebuilt at the application layer.
Retrying safely: what to do after a timeout
The payment button has been spinning for ten seconds and nothing came back. Retry or not?
From chapter 01: an operation is idempotent when running it once and running it many times leave the server in the same state. GET, PUT and DELETE are defined that way, so a client may repeat them after a timeout. POST is not — and a payment is a POST. Watch what goes wrong, and the header that fixes it:
If a second DELETE returns 404, how is DELETE idempotent?
Idempotency is about the state of the server, not about the status code. Delete once and the resource is gone. Delete again and it is still gone — the second request changes nothing, which is exactly what idempotent means. Going from 204 to 404 changes what the server says, not what it did.
Rate limiting: refusing work on purpose
A public API has to survive clients that ask too often, whether on purpose or by accident.
Rate limiting caps how many requests one client may send in a period of time. Over the cap, the server answers 429 Too Many Requests instead of doing the work, and states the rules in response headers. GitHub looks like this:
Retry-After is the standard field: wait at least 42 seconds. It may also carry an HTTP date instead of a number of seconds, so parse both. The x-ratelimit-* fields are GitHub's own convention — the quota, how much is left, and when it resets as a Unix time in seconds. GitHub allows 60 requests per hour without a token. A standard RateLimit-* set is being written at the IETF, but it is still a draft and not ratified, so do not rely on it.The client has a part to play too. Retrying immediately after a 429 only adds load. Use exponential backoff: double the waiting time after each failure. Add a small random amount, called jitter, so that many clients that failed at the same moment do not all come back at the same moment.
Retry-After, the waits are roughly 0.5s, 1s, 2s, and 4s across five attempts, each with a random extra. Line 5 is the important one: retry only what is worth retrying. Backing off from a 400 or a 404 wastes the client's time and does not change the answer.OpenAPI: the description machines can read
Hand-written documentation goes out of date. A definition that generates the documentation does not.
Chapter 04 ended with a table of endpoints — method, path, status codes. OpenAPI is that table written in YAML or JSON in a format tools understand: which endpoints exist, which parameters they take, and what the responses look like.
Swagger UI reads the file and renders a documentation page with a working “Try it out” button. Most large API portals are built this way.
Client SDKs, server stubs, and TypeScript types can all be generated from the definition, so the field names in your code match the API by construction.
The backend is not finished yet? Start a mock server from the definition and let the frontend build against it. Both sides work in parallel against the same contract.
Postman
Postman is the tool most teams use to send API requests by hand, and it can import an OpenAPI file and turn it into a collection of ready-made requests. Its yearly State of the API report is a useful snapshot of what people actually use: in the 2025 report, 93% of respondents worked with REST and 33% with GraphQL.
Practice
The public GitHub API is the best place to practice this chapter: pagination, rate limits, and ETags are all live on it.
Quiz
Nine questions covering pagination, versioning, caching, retries, and rate limits.
You are building a feed. Users scroll continuously and new items keep arriving at the top of the list. Which pagination style fits best?
?page=30000&per_page=20 is much slower than ?page=1. What is the underlying reason?
In the JSON:API convention, in what order does GET /posts?sort=-created_at return the list?
Your blog API already has clients in production. Which of these changes are breaking changes that need a new version? (Select all that apply.)
Put the four steps of ETag revalidation in the right order: ① the server answers 304 with no body ② the first GET returns 200 and an ETag ③ a later request carries If-None-Match ④ the resource has changed, so the server returns 200 and a new ETag
A response carries Cache-Control: no-cache. What is a cache allowed to do with it?
What problem does the Idempotency-Key request header solve?
When a server returns 429 Too Many Requests, it usually adds one response header telling the client how long to wait before trying again. What is that header called? (Two English words joined by a hyphen.)
What is OpenAPI (currently version 3.2)?
- Two ways to paginate. Offset counts rows: simple and able to jump to a page, but slow when the page number is large and unstable when rows are inserted or deleted. A cursor points at the last record you received: stable and fast at any depth, but there is no page to jump to.
?status=filters,?sort=-created_atsorts,?fields=selects fields. The path names the resource; parameters describe how you want it, and they combine freely.- One test decides whether a change needs a new version: would an existing client break? Adding optional fields is safe. Removing a field, changing its type, or changing its meaning is not, and the two versions have to run side by side while clients move. Avoiding breaking changes is cheaper than versioning often.
Cache-Controldecides how long a copy stays fresh.no-cachemeans revalidate before reuse;no-storemeans keep no copy at all. They are not the same directive and not interchangeable.- Revalidation: the server sends
ETag, the client sends it back inIf-None-Match, and an unchanged resource is answered with 304 Not Modified and no body. The body that is not sent is the saving.Last-ModifiedwithIf-Modified-Sincedoes the same with one-second resolution. - A timeout does not mean failure; it means you do not know. An idempotency key lets the server recognize a retry: the same key is executed once, and every repeat gets the stored response from the first attempt.
- 429 means slow down. Honour
Retry-Afterwhen it is present; otherwise back off exponentially and add jitter. TheRateLimit-*fields are still an IETF draft, so treat them as a convention, not a guarantee. - OpenAPI is the endpoint table in machine-readable form. Documentation, client code, and mock servers are generated from one definition, so they cannot drift apart.