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.

REST API DesignPage 1

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
ToolIdea
VersionA named contract; bump only for breaking changes, never additive ones
PaginationA bounded slice plus a way to ask for the next slice
CachingReuse while fresh, revalidate cheaply when stale

2. Core Request Flow

  1. 1Client sendsversion + page position + validators
  2. 2Server returnsdata + next position + cache headers
http
GET /v1/users?limit=20&after=c9f2
If-None-Match: "abc123"

version, page position, validator

http
200 OK
ETag: "abc124"
Cache-Control: max-age=60
{"data":[...],"next_cursor":"d41e"}

data, next position, cache headers

3. Remember

Stable contract

Additive change keeps the version. Every page names its next position. Every cached copy is either fresh or revalidated.

REST API DesignPage 2

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
http
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

SchemeStrengthCatch
URLsimple, discoverableURL changes per version
Headerpurist REST, long-lived APIshidden, needs Vary
?v=2easypollutes cache keys

3. Mistakes

  • Versioning every endpoint separately
  • Bumping the major version for small changes
Pick one

Choose one scheme and version the whole API.

REST API DesignPage 3

Backward Compatibility Rules

1. Safe vs breaking

SafeBreaking
Add optional fieldRemove or rename a field
Add new endpointChange a type
Add optional query paramMake 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

  1. Announce
  2. Add headersDeprecation + Sunset
  3. Monitorusage per version
  4. Removeafter sunset date

4. Example: rename a field

json
{
  "user_name": "ada",
  "username": "ada"
}

transition period: return both fields

Rename = add, then drop

Return both user_name and username, then drop the old one in the next version.

REST API DesignPage 4

Offset vs Cursor Pagination

1. Offset

  • ?limit=20&offset=40 maps to LIMIT 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
sql
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 id as tiebreaker
  • Return an opaque encoded cursor plus next_cursor
  • next_cursor is null at the end

3. Which One

OffsetCursor
Speedslow when deepfast, indexed
Changing dataunstableconsistent
Accessrandom, jump-to-pagenext/prev only
Total page countyesno
Rule of thumb

Use cursor for feeds and large or fast-changing data. Offset is only simple, not safe.

REST API DesignPage 5

Caching with ETag and Cache-Control

1. Cache-Control

DirectiveMeaning
max-age=Nfresh for N seconds
s-maxageshared caches only
no-cachestored, must revalidate
no-storenever stored
publicany cache may store
privatebrowser only, one user

2. ETag and conditional requests

  1. 200 + ETagETag: "abc123"
  2. Client resendsIf-None-Match: "abc123"
  3. 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
http
PUT /users/7
If-Match: "abc123"
-> 412 if the ETag changed

write only if nobody changed it first

3. Common mistakes

  • no-cache is not no-store: it still stores
  • Personalised response cached as public
  • Missing Vary on responses that differ
  • ETag differs per server behind a load balancer
  • max-age on data that changes
Remember

ETag out, If-None-Match in, 304 back. If-Match on writes, 412 on mismatch.

REST API DesignPage 6

Cheat Sheet

1. Versioning and Compatibility

  • URL /v1/users: simple, easy to route
  • Header Accept: application/vnd.example+json; version=2: clean URLs, send Vary: Accept
  • Bump the major version only for breaking changes
  • Safe: add. Breaking: remove, rename, retype, make required
  • Deprecate with Deprecation and Sunset headers

2. Offset vs Cursor

OffsetCursor
SQLLIMIT/OFFSETkeyset WHERE
Jump to pageyesno
Deep pagesslowfast
Changing dataunstablestable
  • Cursor is an opaque token; next_cursor is null at the end

3. Caching

DirectiveMeaning
max-age=Nfresh for N seconds
s-maxageshared caches only
no-cachestore, revalidate first
no-storenever store
public / privateshared / one user only
  1. ServerETag out
  2. ClientIf-None-Match in
  3. Server304 back
  • Add Vary when the response differs by header
  • Writes: If-Match on PUT/PATCH, mismatch gives 412
Mistake

no-cache is not no-store: it still stores, then revalidates.

REST API DesignPage 7

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-cache means 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