Your Old Versions Never Die
Every binary you have shipped is still running somewhere. Version fragmentation is a permanent condition, not a transitional one — and every API change becomes a compatibility negotiation with your own past.
A web deploy replaces the previous version. After the cache expires there is exactly one version of your frontend in existence, and you control it. This is such a basic property of web delivery that people who have only ever worked there do not notice it as a property at all.
Mobile does not work like that. Every binary you have shipped is still running on somebody's device: people with automatic updates disabled, people whose storage has been full since last spring, people on an operating system version your latest build no longer supports and who therefore cannot receive it even in principle. Some installed your app once, used it twice, and will open it again in fourteen months on a version you consider ancient.
This is not a transitional state that resolves. It is the permanent condition of shipping mobile software. Your installed base is a distribution across every version you have released, with a tail that thins asymptotically and never reaches zero. Planning as though everybody will be on the current version is the most common source of avoidable mobile incidents, and they are of a particularly unpleasant type: caused by a change you made today, affecting users you cannot reach, unfixable on the client.
The consequence that matters is this. Your server is not talking to your app. It is talking to every app you have ever shipped, simultaneously, forever. Every backend change is a compatibility negotiation with your own past decisions, and the negotiation is one-sided, because the past cannot be updated.
The distribution is the fact, not the average
Stop thinking about "the current version" and start thinking about the shape of the distribution. A useful adoption curve has three regions, each driving different decisions.
The head. Users who update within days. They tell you whether the release is healthy and are where your staged rollout signals come from. They are not representative in any other respect — newer devices, better connectivity, more engagement — so they are a good early-warning population and a bad sample for anything else.
The body. The bulk of the base, arriving over a longer period, at a rate determined by how often people open your app and by platform update behaviour. Stable enough per product to forecast from, which makes it the basis for sunset planning.
The tail. The population that stops moving: devices that cannot run your minimum operating system version, apps installed and forgotten, users who disabled updates, managed fleets. It does not converge on zero within any planning horizon you care about, and it determines what your servers must still support.
The number to track is a percentile: the version at or above which some high fraction of your active users sit, over a defined window. That percentile, and how long a new release takes to reach it, is your upgrade velocity, and it should be the input to every compatibility decision you make. An average version number is useless; the tail is what costs you money.
Every API change is a negotiation with your past
Once you accept that all your old clients are live, backend API design changes character. The rule is simple to state and demanding to follow: additive changes are safe, everything else is a migration.
Adding an optional field is safe — as long as old clients genuinely ignore it. Strict deserialisation that rejects unknown fields turns every additive change into a breaking one, and that is a decision made once, early, usually by accident, that constrains you for years. Configure clients to tolerate unknown fields from the first version you ship.
Removing a field is a breaking change, even if you are confident nothing uses it. Confidence about what old clients do is exactly what you do not have, because the code is not in front of you and its author has moved on.
Changing a field's meaning is worse than removing it, because it fails silently. A removed field produces a null and usually an obvious defect. A reinterpreted field produces plausible wrong behaviour nobody notices for a month.
Changing enumeration values requires clients that already tolerate unknown ones. If version one crashes on an unrecognised status value, you can never add a status. Give every enumerated type a defined unknown case from the beginning, and test that path.
Tightening validation breaks old clients. Requests that were acceptable become rejected — frequently overlooked, because nothing in the schema moved.
For genuine breaks there are two workable strategies. Versioned endpoints put explicit versions in the path or a header, with old versions maintained until their clients are gone; easy to reason about, but you accumulate versions, each with its own code path and sunset work. Tolerant readers on a stable contract use one endpoint evolved only additively; the contract accumulates deprecated fields you can never remove, but there is one code path and no sunset work.
Most mobile backends should use the second as the default and the first only for architectural breaks. A backend-for-frontend is valuable here: it concentrates version handling in one service whose job is translating between whatever your clients understand and whatever your domain services look like now, keeping compatibility debt out of the core.
| Change | Safe for old clients | What to do |
|---|---|---|
| Add optional field | Yes, if clients tolerate unknown fields | Ship it |
| Add enumeration value | Only if clients have an unknown case | Verify the client's fallback path first |
| Remove field | No | Deprecate, wait for adoption, then remove |
| Rename field | No | Add the new one, populate both, remove later |
| Change field meaning | No, and it fails silently | Use a new field, never reinterpret |
| Tighten validation | No | Version the endpoint or gate by client version |
| Change error semantics | Usually not | Treat as breaking — old clients handle errors invisibly |
Forced upgrade is infrastructure, not a feature
At some point you will need to stop supporting a version — a security defect, an endpoint you must retire, data corruption — and you will need a mechanism to tell a running client it must update before continuing.
That mechanism must exist in version one, because it only works on versions that already contain it. A forced-upgrade capability added in version twelve does nothing for versions one through eleven, which are precisely the versions you will want to force off. Safety mechanisms only protect the builds that shipped with them.
The client sends its version on every request, or at least on a lightweight check at launch. In a header, consistently, from the first release.
The server can respond with an upgrade directive, at two levels. A soft directive shows a dismissible prompt; a hard directive blocks use until the user updates. Soft prompts are for approaching sunsets and are how you move the adoption curve without annoying anyone. Hard blocks are for security and data-integrity problems and should be rare, because a hard block on a user who cannot update — old device, no storage, no connectivity — is an app they can never open again.
The rule is server-side and expressed as a range, not compiled into the client: minimum supported version, ideally per platform and per operating system version, held in configuration. If the rule lives in the binary you have to ship to change it, which defeats the purpose.
The blocked state must be usable. A clear explanation, a working link to the store listing, and a support route; a dead end converts a technical decision into a reputation problem. A read-only mode, cached content or an account-export path can be worth keeping available, depending on why you are blocking.
Test the blocked path in every release. It runs approximately never and must work on the day it matters. Build it into the regression suite with an old version number, or it will rot silently and you will find out during the incident.
Sunset policy, written down in advance
Deciding version support ad hoc, during an incident or a migration, produces inconsistent decisions and arguments with stakeholders who did not know it was coming. A written policy costs an afternoon and removes the argument permanently. A serviceable one answers four questions.
How long is a version supported? Combine a time period and an adoption threshold — supported for a defined period after release, or until usage falls below a stated share of active users, whichever is later. The threshold protects you from sunsetting something still in real use; the period protects you from supporting a version forever because of a stubborn tail.
What does "supported" mean? Typically that the API contract it depends on keeps functioning and that security defects will be addressed. Typically not new features, and it should not imply the experience will be identical.
What is the notice period, and how is notice given? In-app soft prompts are the main channel, supplemented by support documentation, release notes and any direct channel you have. Notice starts well before the sunset and escalates.
Who approves an exception? There will be exceptions — an enterprise customer with a managed fleet, a regulated context, a region where device replacement is slow. Name the decision-maker in advance, so an exception is a decision rather than an escalation.
Publish it where support, account management and product can see it, because the people who will field the consequences are not on the engineering team.
The server-side cost of the tail
Supporting old clients is not free, and the cost is mostly invisible because it is distributed across many small accommodations rather than concentrated in one line item.
Conditional logic accumulates. Branches on client version, spread through services, each individually reasonable. Over years this becomes a significant fraction of your API layer, hard to test because the conditions depend on client behaviour nobody remembers.
The test matrix widens. Every supported version is a dimension, you will not test all combinations, so coverage of the compatibility surface is thin and breakages are found by users. Deprecated fields must also keep being populated, sometimes meaning a data path whose only consumer is a client version you wish did not exist.
Old clients are often the expensive ones. They may poll more aggressively, lack pagination you added later, request larger payloads, or retry without backoff. The tail can consume a disproportionate share of backend capacity, which is worth measuring rather than assuming.
Security posture is set by the oldest supported client. Retiring a weak cipher, a legacy authentication flow or an old certificate handling path is gated by whether your oldest supported version copes. This is frequently the real reason a security improvement is blocked, and rarely stated that way in the ticket.
Incident diagnosis is harder. "It is broken" now requires knowing which version, which ties directly into what you instrument for release health.
Make the cost visible so it can be traded off. Track the number of version conditionals in your backend, the deprecated fields still populated, and the share of backend load attributable to versions past their sunset. These turn "we should probably drop the old clients" into a case with a size attached, and cases with sizes attached get prioritised.
What to do on Monday
Produce your version distribution across active users for the last thirty days. Not the average — the full distribution, with everything older than the last three releases grouped into a tail. Most teams have not looked recently and are surprised by what they find.
Check whether you have a working forced-upgrade path in the versions your users are running today. Not in the current codebase — in the shipped builds. If not, that is your next piece of infrastructure, ahead of whatever is currently next, because every day you delay adds another cohort you can never reach.
Then write the sunset policy. One page, four questions, a named approver for exceptions. Circulate it to support and account management before engineering, because they are the ones who will discover whether it is workable.