Web deployments update the whole world at once. Ship a new backend, and every browser tab gets the new contract on its next request. Mobile is nothing like that. A user who installed your app eight months ago and never updates it is still calling your API today, with an app binary you can't force-update and a contract you can't retroactively change on their device.
That single fact should shape how you think about every breaking change to a mobile API. Here's a practical approach that keeps old installs working without freezing your backend in place forever.
Step 1: Put a Version in Every Request
If your API doesn't already send the calling app's version with every request (a custom header is simplest), add that first. Nothing else in this guide works without knowing which contract version a given request expects. Most teams retrofit this under pressure after a breaking change already shipped; do it now, while there's no incident forcing the decision. The Semantic Versioning specification is worth adopting for your app's own version numbers even if your backend API itself doesn't follow semver internally, since it gives your team a shared, unambiguous vocabulary for talking about which changes are safe additions versus breaking changes.
Step 2: Treat Field Additions as Free, Field Removals as Breaking
Adding a new optional field to a response is safe. Old app versions ignore fields they don't recognize; this is how JSON-based APIs are supposed to evolve. Removing a field, renaming a field, or changing a field's type or meaning is not safe, because an old app version is still reading the old contract and will either crash or silently misbehave.
Before removing or renaming anything, check how many active installs are still on app versions that depend on the old shape. If a meaningful percentage are still on old versions (a mobile analytics tool will tell you this directly), you have to support both shapes simultaneously for a transition period. Services like Firebase Analytics or Amplitude both expose app-version breakdowns directly in their dashboards, which is usually enough to answer "can we safely remove this field yet" without building a custom reporting query.
A related trap: type changes that look harmless in a strongly-typed backend language can still break a client silently. Changing a field from an integer to a string, for instance, compiles fine on your server and looks like a minor detail, but a mobile client expecting a number will either crash on deserialization or coerce the value in a way that produces subtly wrong behavior, depending on how strict its parsing layer is.
Step 3: Branch on Version, Not on Feature-Detection Guesses
Once you know which app version made a request, the backend can branch explicitly: serve the old response shape to old versions, the new shape to new versions. This is more backend code temporarily, but it's honest code, versus trying to construct a single response shape that happens to work for both old and new clients through coincidental backward compatibility. Explicit version branches are also far easier to delete later, once old versions age out of your active install base, than implicit compatibility hacks are to untangle.
Step 4: Set an Actual Sunset Policy
Decide, in advance and in writing, how long you'll support an old app version's API contract: six months, a year, tied to app store policies for minimum supported OS versions, whatever fits your user base. Without an explicit policy, "support old versions" quietly becomes "support every version forever," and your backend accumulates permanent branches for contract versions almost nobody uses anymore.
When a version does sunset, have the API return a clear, structured error that the app can render as "please update," rather than a raw 400 or 500 that just looks like a bug to the user. This is also the point where coordinating with your app store listings matters: if you're going to force an update, make sure the store description and release notes actually explain what changed, rather than leaving users to discover a forced update with no context for why it happened.
Step 4.5: Consider a Deprecation Header Before a Hard Cutoff
Rather than jumping straight from "fully supported" to "hard error," a middle step helps: add a deprecation warning header or field to responses served to soon-to-be-unsupported app versions, well before the actual sunset date. If your app can read that field and show a soft in-app "please update soon" banner, you convert a chunk of your stale install base to current versions voluntarily, before the hard cutoff forces the issue. This only works if the deprecation signal is added months, not days, ahead of the actual sunset, giving users a real window to update on their own schedule.
Step 5: Coordinate With Deep Linking and Push Payloads
Contract versioning isn't only about REST response bodies. Push notification payloads and deep link parameter shapes are contracts too, and they suffer from exactly the same old-install problem. A push notification payload shape you change today still needs to be parseable by whatever version of your app is installed on a phone that hasn't opened the App Store in six months. We cover how stale installs interact with routing specifically in our guide to deep linking through cold starts, which runs into this same "old client, new contract" problem from the routing side rather than the API side.
Step 5.5: Don't Forget Client-Side Validation Drift
A subtler version of this problem shows up in client-side validation logic, not just response parsing. If your backend adds a new validation rule (a field that used to accept any string now requires a specific format, for instance), an old app version's client-side validation doesn't know about the new rule and will happily submit data the backend now rejects. The user sees a confusing server error for an action that felt completely normal from the old app's perspective, since nothing in the old app's UI indicated the new constraint existed.
Handle this the same way as response-shape versioning: either accept the old, looser format from old app versions indefinitely (if that's feasible), or have the backend return an error specific and clear enough that even an old app's generic error handling can show something better than a raw failure message.
Testing This Before It Becomes a Production Incident
Maintain a small suite of "old contract" integration tests that specifically exercise your API using request and response shapes from your oldest still-supported app version, not just your current one. Most teams's automated test suites exclusively test against the current contract, which means a backend change that breaks an old, still-supported version can pass every test in CI and only surface once real stale-version traffic hits production. Explicitly testing against archived old-version fixtures catches this class of regression before release rather than after.
The Discipline That Actually Matters
None of this requires exotic infrastructure. It requires treating "what contract does this specific request expect" as a first-class question your backend can always answer, and being honest about how long you're willing to keep answering it for versions that are aging out. Teams that skip this step don't find out until a support ticket says the app "just stopped working" for a user who, reasonably, doesn't see why not updating for a year should be their problem.
Where Most Teams Actually Get Stuck
The technical mechanics of version-branching a backend aren't the hard part. The hard part, in our experience working through this on real client codebases, is organizational: getting agreement across product, mobile, and backend teams on an actual sunset timeline, in writing, before a breaking change ships rather than after complaints roll in. Without that agreement up front, "how long do we support old versions" gets decided reactively, under pressure, during an incident, which is the worst possible time to make a policy decision calmly.
Write the sunset policy down somewhere visible before you need it: a wiki page, a section in your API documentation, anywhere a backend engineer making a change six months from now will actually see it before shipping. The policy doesn't need to be complicated. "We support app versions released within the last twelve months, and we require a deprecation warning at least sixty days before removing support for any contract shape" is a complete policy. Having any explicit answer, agreed on ahead of time, beats improvising one during an active incident every time.
A Quick Gut Check for Your Current API
If you're unsure whether your team already has this problem, a fast diagnostic: pick any field in your current API response and ask whether you know, right now, how many active app installs would break if that field disappeared tomorrow. If the honest answer is "we'd have to go check" or "we're not sure," that's the gap this whole approach closes. Version tracking on every request is what turns that question from a scramble into a five-minute dashboard lookup.
Top comments (0)