Versioning without breaking clients
What counts as a breaking change, why tolerant readers make most changes safe, and the trade-offs between path, header and date-based versions.
After this lesson you can
- Classify an API change as breaking or non-breaking
- Compare URI-path, header and media-type versioning
- Plan a deprecation that gives clients time to move
Once an API has clients you do not control — mobile apps that users never update, partners with quarterly release cycles — every change is either safe for all of them or breaks some of them. Versioning is how you ship the second kind without breaking anyone who has not opted in.
Breaking and non-breaking
A change is breaking when a correct, existing client stops working. The usual list:
- removing or renaming a response field, or changing its type;
- adding a required request field, or making an optional one required;
- changing the meaning of a field, a status code, or a default;
- tightening validation that used to accept a value;
- removing an endpoint, a method or a request field the API would now reject.
Usually non-breaking: adding an endpoint, adding an optional request field, adding a response field. That last one is only safe if clients are tolerant readers — they ignore fields they do not know. Document that as a rule of the API, because a client that validates responses strictly turns every addition into a break:
const KNOWN = ["id", "status"]; function strictClient(body) { const unknown = Object.keys(body).filter((k) => !KNOWN.includes(k)); if (unknown.length) throw new Error(`unexpected field ${unknown[0]}`); return body.status;} function tolerantClient(body) { return body.status;} function attempt(client, body) { try { return client(body); } catch (e) { return `error: ${e.message}`; }} export function run() { const before = { id: "7", status: "paid" }; const after = { id: "7", status: "paid", paidAt: "2026-03-01T09:30:00Z" }; return { strict: [attempt(strictClient, before), attempt(strictClient, after)], tolerant: [attempt(tolerantClient, before), attempt(tolerantClient, after)], };}One trap sits in the middle: adding a value to an enum. A client with an
exhaustive switch over status crashes or mislabels the new value.
Tell clients up front that enums may grow, and that unknown values need a
default branch.
Where the version goes
- URI path —
/v1/orders,/v2/orders. Visible in every log and link, trivial to route, cache-friendly. The cost: a "version" applies to the whole API and resources get two URIs. - Request header — a custom header such as
Api-Version: 2, or a media type,Accept: application/vnd.example.v2+json. URIs stay stable and resources keep one identity, but the version is invisible in a browser and every cacheable response must carryVaryon that header. - Query parameter —
?version=2. Easy to try out, easy to forget. - Date-based pinning — some APIs name versions by release date and pin each client account to the version current when it started; the client upgrades by changing its pin.
const RELEASES = ["2024-01-15", "2024-09-01", "2025-03-10"]; // The newest release on or before the pinned date.function resolve(pin) { const eligible = RELEASES.filter((r) => r <= pin); return eligible.length ? eligible[eligible.length - 1] : null;} export function run() { return ["2024-12-01", "2025-03-10", "2023-06-30"].map((pin) => `${pin} -> ${resolve(pin)}`);}None of these is the standard; consistency and a deprecation policy matter more than the mechanism.
Deprecation
Version only for breaking changes, and run old and new side by side.
Announce the dates in documentation and in responses. Two standard
response headers exist for this: Deprecation (RFC 9745, 2025) carries
the date the resource was or will be deprecated, as a structured-field
date such as Deprecation: @1767225600, and Sunset (RFC 8594) carries
the HTTP date after which the resource is expected to stop responding.
Measure which clients still call the old version before switching it
off.
Common mistakes
- Bumping the version for additive changes, so clients face a migration for nothing.
- Calling a change "non-breaking" because it only adds a required field or tightens validation.
- Header-based versions without
Vary, so a cache returns a v1 body to a v2 client. - Adding enum values without having told clients to expect them.
What interviewers ask
- Is adding a field to a response a breaking change? — not for tolerant readers, which an API should require; it is for clients that reject unknown fields.
- URI versioning or header versioning? — path is explicit, cacheable and
easy to route; header keeps URIs stable but needs
Varyand is less visible. Either works if it is consistent. - How do you retire v1? — run both, announce the dates (docs, plus
DeprecationandSunsetheaders), track remaining callers, contact them, then switch off.
Exercise · Pro
Find the breaking changes
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade