Handbooks / REST API Design / Chapter 4
Versioning and Pagination
7 pages · ~5 min✓ Reviewed
Builds on Authentication and Authorization. Next up: Rate Limiting and Idempotency.
API Design Mental Model
1. Three Tools, One Goal
- Versioning, pagination and caching all protect clients
- The contract stays stable while data and servers change
| Tool | Idea |
|---|---|
| Version | A named contract; bump only for breaking changes, never additive ones |
| Pagination | A bounded slice plus a way to ask for the next slice |
| Caching | Reuse while fresh, revalidate cheaply when stale |
2. Core Request Flow
- 1Client sendsversion + page position + validators
- 2Server returnsdata + next position + cache headers
GET /v1/users?limit=20&after=c9f2 If-None-Match: "abc123"
version, page position, validator
200 OK ETag: "abc124" Cache-Control: max-age=60 {"data":[...],"next_cursor":"d41e"}
data, next position, cache headers
3. Remember
Additive change keeps the version. Every page names its next position. Every cached copy is either fresh or revalidated.
URL vs Header Versioning
1. Two Schemes
- URL:
/v1/users, visible, easy to route, curl and cache - Catch: the URL (resource identity) changes per version
- Header:
Accept: application/vnd.example+json; version=2 - Or
X-API-Version: 2; URLs stay clean - Catch: hidden, hard to test in a browser
GET /users HTTP/1.1 Accept: application/vnd.example+json; version=2 HTTP/1.1 200 OK Vary: Accept
version in a header, URL unchanged
2. Compare and Cache
| Scheme | Strength | Catch |
|---|---|---|
| URL | simple, discoverable | URL changes per version |
| Header | purist REST, long-lived APIs | hidden, needs Vary |
?v=2 | easy | pollutes cache keys |
3. Mistakes
- Versioning every endpoint separately
- Bumping the major version for small changes
Choose one scheme and version the whole API.
Backward Compatibility Rules
1. Safe vs breaking
| Safe | Breaking |
|---|---|
| Add optional field | Remove or rename a field |
| Add new endpoint | Change a type |
| Add optional query param | Make optional param required |
| Add enum value (tolerant clients only) | Change status codes or error shapes |
| Change semantics |
2. Tolerant reader
- Clients ignore unknown fields
- Servers accept unknown optional input
- Together they make additive change safe
3. Deprecation flow
- Announce
- Add headersDeprecation + Sunset
- Monitorusage per version
- Removeafter sunset date
4. Example: rename a field
{
"user_name": "ada",
"username": "ada"
}transition period: return both fields
Return both user_name and username, then drop the old one in the next version.
Offset vs Cursor Pagination
1. Offset
?limit=20&offset=40maps toLIMIT 20 OFFSET 40- Supports jump-to-page and totals
- Slow on deep pages: the DB scans and discards skipped rows
- Unstable: inserts or deletes between requests cause duplicate or missing items
2. Cursor (keyset)
?limit=20&after=<opaque token>- Uses an index, so it stays fast at any depth
SELECT * FROM items WHERE (created_at, id) < (:c, :i) ORDER BY created_at DESC, id DESC LIMIT 20
the last row of a page gives :c and :i
- Needs a unique, stable sort key; add
idas tiebreaker - Return an opaque encoded cursor plus
next_cursor next_cursorisnullat the end
3. Which One
| Offset | Cursor | |
|---|---|---|
| Speed | slow when deep | fast, indexed |
| Changing data | unstable | consistent |
| Access | random, jump-to-page | next/prev only |
| Total page count | yes | no |
Use cursor for feeds and large or fast-changing data. Offset is only simple, not safe.
Caching with ETag and Cache-Control
1. Cache-Control
| Directive | Meaning |
|---|---|
max-age=N | fresh for N seconds |
s-maxage | shared caches only |
no-cache | stored, must revalidate |
no-store | never stored |
public | any cache may store |
private | browser only, one user |
2. ETag and conditional requests
- 200 + ETagETag: "abc123"
- Client resendsIf-None-Match: "abc123"
- 304no body, unchanged
- ETag = validator, a fingerprint of one representation
- Unchanged → 304 Not Modified, saves bandwidth
If-Match: <etag>on PUT/PATCH = optimistic concurrency- Mismatch → 412 Precondition Failed
PUT /users/7 If-Match: "abc123" -> 412 if the ETag changed
write only if nobody changed it first
3. Common mistakes
no-cacheis notno-store: it still stores- Personalised response cached as
public - Missing
Varyon responses that differ - ETag differs per server behind a load balancer
max-ageon data that changes
ETag out, If-None-Match in, 304 back. If-Match on writes, 412 on mismatch.
Cheat Sheet
1. Versioning and Compatibility
- URL
/v1/users: simple, easy to route - Header
Accept: application/vnd.example+json; version=2: clean URLs, sendVary: Accept - Bump the major version only for breaking changes
- Safe: add. Breaking: remove, rename, retype, make required
- Deprecate with
DeprecationandSunsetheaders
2. Offset vs Cursor
| Offset | Cursor | |
|---|---|---|
| SQL | LIMIT/OFFSET | keyset WHERE |
| Jump to page | yes | no |
| Deep pages | slow | fast |
| Changing data | unstable | stable |
- Cursor is an opaque token;
next_cursorisnullat the end
3. Caching
| Directive | Meaning |
|---|---|
max-age=N | fresh for N seconds |
s-maxage | shared caches only |
no-cache | store, revalidate first |
no-store | never store |
public / private | shared / one user only |
- ServerETag out
- ClientIf-None-Match in
- Server304 back
- Add
Varywhen the response differs by header - Writes:
If-Matchon PUT/PATCH, mismatch gives412
no-cache is not no-store: it still stores, then revalidates.
Check yourself
You page with limit=20 and fetch offset=0. Then 3 new items are inserted at the top. What does offset=20 return?
- The last 3 items of page 1 again: duplicates
- A cursor avoids this: it anchors on the last row seen
A response has ETag "v7" and Cache-Control: no-cache. The client resends If-None-Match: "v7" and the data is unchanged. What comes back?
- 304 Not Modified, with no body
no-cachemeans store but revalidate, not never store
A cursor sorts by created_at only. 5 rows share one timestamp and a page ends in the middle of them. What breaks?
- Rows after the cut get skipped or repeated
- Fix: sort by (created_at, id), a unique key