Versioning Without Breaking Clients
You shipped a clean little rename. price_cents became amount, because price_cents had been a lie since the day you added a second currency. The web app picked up the new field the same afternoon. Done by lunch.
Three weeks later the support queue was still on fire. Roughly a fifth of the mobile users were running a build compiled before the rename. Their checkout screen still read price_cents, that key no longer existed, and every one of them saw a total of $NaN. You could not fix it by deploying. Their phones were running code you shipped in March, sitting behind an app-store review queue and a hundred thousand devices with auto-update switched off.
That is the whole game. On the server you own every line and you can redeploy in ninety seconds. The clients are someone else’s problem: old phones, a partner’s nightly cron job, a smart TV, a point-of-sale terminal bolted to a wall since 2021. Change the shape of what you return and you are not editing your code. You are editing theirs, without asking, from a distance, on devices you will never touch.
So the real skill is not “how do I build v2”. It is “how do I change almost everything while never once cutting the cord on v1”.
What actually counts as breaking
A change is breaking if a client that worked yesterday stops working today, with no edit on their side. That is the only definition that matters, and it splits every change you could make into two piles.
The breaking pile is longer than people expect. Here is what belongs in it:
- Removing or renaming a response field. The client reads the old name and gets
undefined. - Changing a field’s type. A number becomes a string, a scalar becomes an object, a single value becomes an array. A strict
=== 42check now fails even though the value looks the same. - Tightening request rules. Making an optional field required, adding a brand-new required field, shortening a max length, narrowing an accepted set of values. The old client sends what it always sent and now gets rejected. This is the sneakiest kind because the response never changed, so it slips past reviewers. See validating input for how easy it is to tighten a rule without realizing who it locks out.
- Changing a default. If
limitused to default to 20 and now defaults to 50, a client that relied on the old number quietly gets different behavior. - Changing the meaning or units of a field.
amountin cents becomesamountin dollars. Nothing in the shape changed. Every invoice is now wrong by 100x. - Changing status codes or the error body. A client that branches on a 404 or reads
error.codebreaks when you move it. Status codes and error shapes are part of your contract whether you meant them to be or not.
The safe pile is short and boring, which is the point:
- A new optional field in the response.
- A new endpoint.
- A new optional request parameter with a sensible default.
- Relaxing a validation rule so you accept more than before.
The rule underneath: be a tolerant reader
Look again at the safe pile. Every entry on it is only safe if the client ignores what it doesn’t recognize. Add a field and a picky client that rejects any unknown key will still blow up. The safety of additive change is not a property of the server alone. It is a pact, and the client has to hold up its end.
That end has a name. Be conservative in what you send, be liberal in what you accept. Write clients that take what they need, ignore the rest, and never assume the payload contains exactly the keys they know about. A reader built that way survives almost every additive change you will ever ship.
function renderOrder(o) {
// accept both names, prefer the new one, tolerate either being absent
const amount = o.amount ?? o.total ?? 0;
const currency = (o.currency ?? "usd").toUpperCase();
const status = o.status ?? "unknown"; // default branch for future enum values
return `${(amount / 100).toFixed(2)} ${currency} · ${status}`;
}
That function keeps working when the server renames total to amount, adds a currency, and invents three new statuses. It reads defensively, so the server has room to move. Contrast it with the client that did const total = o.total and called it a day. One line, one outage.
Before we talk versions, get a feel for the split. Here is a diff of a change to a response or a request. Call it before you read the answer.
Get good at this split and you version far less often than you think. Most days, additive change plus a tolerant client is the entire strategy. Versioning is the thing you do when additive isn’t enough.
Where you put the version
Sooner or later you hit a change that cannot be additive. You need to remove a field that half your response was built around, or flip a default that fraud rules depend on. Now you need two shapes live at once, and the client has to tell you which one it wants. There are three places to put that signal.
URL versioning: ugly, obvious, and the one to pick
Put the version in the path. GET /v1/orders, then GET /v2/orders. It is the least elegant option and the best default, and those two facts are related.
It wins on the things that actually cost you time. You can paste a URL into a browser and see exactly which version you hit. It shows up in every access log without extra work. Each version is a distinct URL, so caches, CDNs, and proxies key on it for free, no Vary gymnastics. Routing is a one-liner. A new engineer understands it in four seconds.
app.use("/v1", v1Router);
app.use("/v2", v2Router);
The purist objection is that a URL should name a resource, and /v1/orders/42 and /v2/orders/42 name the same order, so the version is polluting the identity. That objection is correct and it does not matter. The version isn’t identifying the order, it’s identifying the representation you want, and being able to see it in plain text is worth more than the theory. It is also coarse: bumping to /v2 implies the whole surface moved, even if you only touched one endpoint. Live with it. The clarity pays for the bluntness.
Header and media-type versioning: purer, and rarer for a reason
Keep the URL clean and move the version into a header. Either a plain custom header or a versioned media type in Accept, which is content negotiation the way HTTP intended.
GET /orders/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
The URL now identifies the order and nothing else, which satisfies the theory and lets you version a single resource without touching the rest. The cost is everything the URL approach gave you for free. You cannot try it from a browser address bar. It vanishes from a casual look at the logs. Every intermediary cache has to Vary: Accept or it will happily serve a v2 body to a v1 client, which is a genuinely nasty bug to chase.
Query parameter versioning
GET /orders/42?version=2. Some platforms do this, notably Azure with its api-version query parameter. It is visible like the URL approach and settable without touching headers, which is its whole appeal. But it muddies caching by folding a representation choice into the same query string you use for real filters, and some proxies and link-rewriters treat query params as disposable and strip them. It sits in an awkward middle: not as clean as a header, not as cache-friendly as a path. Fine if you inherit it, rarely the thing to reach for.
The option nobody advertises: don’t version at all
Here is the strategy a surprising number of large APIs actually run. They never cut a v2. They evolve one surface, forever, additively, and they lean hard on tolerant clients to absorb it. GraphQL bakes this into the culture: the official guidance is to add fields, mark old ones @deprecated, and never stand up a parallel schema. Google’s API guidelines push the same way, treating a new major version as a last resort you justify, not a routine release.
It works precisely because of the first half of this article. If every change is additive and every client is tolerant, the version number is dead weight. You only need /v2 the day you must do something genuinely subtractive, and if you plan well, that day is rare. The best versioning strategy is the one you almost never have to use.
Expand and contract
When you do have a breaking change, the move is to make it in three deploys instead of one, so that at no single moment does the old shape and the new shape fail to overlap. Expand, then migrate, then contract.
Expand. Add the new thing next to the old thing. Emit amount alongside total, both set to the same value. Nobody has to change anything yet. Old clients read total, and it is still there.
Migrate. Move every reader and writer you control over to amount. Your own web app, your SDKs, your internal services. This is the slow phase, and it can take as long as it takes because both fields are live the whole time.
Contract. Once nothing reads total, delete it. Only now, and only because you can prove the previous step finished, does the old field go away.
If this feels familiar, it should. It is the same dance you run against a live database when you rename a column without downtime: add the new column, backfill it, dual-write to both, cut reads over, then drop the old column in a later migration. Same shape, same reason. You cannot atomically change a running system and everything reading it, so you overlap the old and the new until the readers have all moved.
-- expand: add the new column, backfill from the old one, keep both
ALTER TABLE orders ADD COLUMN amount_cents integer;
UPDATE orders SET amount_cents = total_cents WHERE amount_cents IS NULL;
-- application dual-writes to both columns during the migrate phase
-- contract (a later migration, after all readers moved):
ALTER TABLE orders DROP COLUMN total_cents;
The discipline that makes this safe is refusing to combine steps. An “add amount and drop total” pull request looks efficient and is a landmine, because the instant it deploys, every client still on total is broken. Two boring deploys beat one clever one.
One codebase, many shapes
The mistake that makes versioning miserable is treating v2 as a copy. Someone forks the v1 handler, edits the fork, and now there are two order code paths that must both be patched for every bug and every security fix, forever. Six months later they have drifted, and a fix that landed in v2 quietly never made it to v1.
Route both versions into the same core logic, and let a thin adapter per version reshape the edges.
The core returns the newest, cleanest shape. The current version is a pass-through. Every older version is a small function that translates the core shape back to the promise it made.
// one implementation, no version awareness anywhere inside it
async function getOrder(id) {
// ...the real work: query, authorize, compute...
return { id, amount: 500, currency: "usd", status: "paid" };
}
// v2 is the current shape: hand the core straight back
v2Router.get("/orders/:id", async (req, res) => {
res.json(await getOrder(req.params.id));
});
// v1 is a translation of the same data into the old field names
v1Router.get("/orders/:id", async (req, res) => {
const o = await getOrder(req.params.id);
res.json({ id: o.id, total: o.amount, currency: o.currency });
});
A bug in how orders authorize gets fixed once, in getOrder, and both versions inherit the fix. The only thing that lives per-version is the translation, and a translation is easy to read, easy to test, and easy to delete when the version retires. When you contract, you delete one adapter and one route mount. The core never knew v1 existed.
Deprecating without an angry mob
A version you stand up is a version you will eventually want to tear down, because every one you keep alive costs real money in test surface, on-call confusion, and security patches applied in two places. Retiring one is a process, not an announcement, and it runs on a clock and on data.
Say it in the response, not just the changelog. Nobody reads your changelog. They do read headers, or their tooling does. HTTP has two standard ones for exactly this. Deprecation carries the moment the endpoint became deprecated, and Sunset carries the moment it stops answering. A Link header points at the migration guide.
HTTP/1.1 200 OK
Deprecation: @1717200000
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: <https://api.example.com/docs/v1-sunset>; rel="sunset"
The Deprecation value is a Unix timestamp with an @ in front (that is the structured-field date format the spec settled on), and the Sunset value is a plain HTTP date. The sunset must not be earlier than the deprecation, which is just the rule that you cannot turn something off before you have announced it is on the way out. Setting them is a two-line middleware on the old router.
function announceSunset(req, res, next) {
res.set("Deprecation", "@1717200000");
res.set("Sunset", "Wed, 31 Dec 2025 23:59:59 GMT");
res.set("Link", '<https://api.example.com/docs/v1-sunset>; rel="sunset"');
next();
}
v1Router.use(announceSunset);
Then find out who is actually still on it, by name. A date on a header does nothing if you cannot tell whether anyone is listening. Log the version, the route, and the caller on every request, and the question “who breaks if I turn off v1 tonight” becomes a query instead of a prayer.
log.info({
apiVersion: "v1",
route: "/orders/:id",
clientId: req.auth?.clientId,
});
Now your telemetry can rank the top v1 callers, and you email those specific teams, not a mailing list. The curve in the diagram is the whole point of the exercise: you are not watching a calendar, you are watching traffic drain, and you flip the switch when it is a trickle from a handful of clients you have already spoken to, not on a hard date while 30% of requests are still arriving. When the endpoint finally goes, answer it with a 410 Gone and a body that names the replacement, so the one integration that missed every email gets a clear message instead of a confusing 404.
Two versions cost more than twice as much
Every version you keep breathing is a standing tax. Two full test suites. Two sets of docs that drift. Two places a security fix has to land. A translation adapter that is itself code someone can break. And the quiet one: an on-call engineer at 3am now has to figure out which version the paging alert is even about. The cost is not linear, because bugs and audits and questions all multiply across the versions, and the seams between them are their own surface.
So decide how a version dies before you give birth to it. Write the exit criteria into the same document that proposes v2. A hard sunset date. A traffic threshold, like “v1 turns off once it is under 1% of requests for two straight weeks”. A named owner who watches the curve. Without that, versions do not retire. They accumulate, and five years later you are the team maintaining /v1 through /v6 because nobody was ever allowed to be the one who broke a customer.
The cheapest version is the one you never shipped. The second cheapest is the one you shipped with its own funeral already on the calendar.
Summary
- You cannot redeploy other people’s clients. Mobile apps, partner scripts, and embedded devices run code you shipped months ago, so a breaking change is an outage you cannot patch.
- A change is breaking if a working client stops working with no edit on their side: removing or renaming a field, changing a type, tightening request validation, changing a default, a unit, a status code, or an error shape.
- Additive change (a new optional field, a new endpoint, a relaxed rule) is safe only if clients are tolerant readers. Take what you need, ignore the rest, and keep a default branch so a new enum value never falls through.
- URL versioning (
/v1) is the pragmatic default: ugly but obvious, cache-friendly, and trivial to route. Header and media-type versioning is purer and is what dated platforms like Stripe and GitHub use, at the price of real tooling. Query params sit in an awkward middle. - Many large APIs barely version at all, evolving one surface additively for years and deprecating fields in place. It is the best strategy when you can hold to it.
- Make breaking changes with expand, migrate, contract: add the new alongside the old, move every reader over, then remove the old in a later deploy. Same trick as a zero-downtime column rename.
- Route every version into one core implementation with a thin per-version translator. A fork means fixing every bug twice.
- Deprecate on a clock and on data: ship
DeprecationandSunsetheaders, use your logs to find and email the real stragglers, and sunset with a410 Goneonly when traffic is near zero. - Two versions cost more than twice one. Write the exit plan before you ship the version.