TL;DR: A web deploy replaces every user's version at once. A mobile release does not. Once you ship through the App Store and Play Console, you are running a population of app versions in the wild, and some of them will stay there for months. The backend has to serve all of them. The rules I follow: every request says which app version sent it, API changes are additive only, clients tolerate data they do not recognise, a minimum-version gate and remote flags ship in version one, business logic stays on the server, and old production builds get tested against every backend change. I learned most of this shipping React Native, Unity and web clients against one backend as CTO at RAQTS, and from building an SDK that lived inside other people's apps at Geonode.
A bug fixed in a Next.js portal is live for everyone within minutes. A mobile app breaks that assumption quietly: the release you shipped today joins the ones you shipped last month, and they all keep talking to the same server.
Why old app versions never really die
At RAQTS, where I stepped in as CTO of a sports-tech platform, one product lived in three runtimes: a Unity interactive layer, React Native mobile apps and a Next.js web portal. The portal was the easy one. The mobile apps were where compatibility became a permanent part of the job, for reasons that have nothing to do with code quality:
- Store review sets the calendar. A build waits for review before anyone can install it, so a fix is never instant, and a rejection can push it back further.
- Phased rollouts are multi-version by design. A staged release on Play or a phased release on the App Store deliberately puts the new version on a fraction of devices while everyone else stays on the old one.
- Users do not update when you do. Automatic updates get switched off, phones sit on old OS versions that cannot install your newest build, and some people simply open the app twice a month.
The result is a long tail. Look at the version breakdown in your analytics a few weeks after any release and you will usually find several versions still active. Each of them was built against the API as it existed on the day it shipped, and each of them will call your server exactly the way it always did, forever, until the user updates or deletes it.
So the right mental model is not "the app" and "the backend". It is one backend serving a set of clients you no longer control, each frozen at a different moment in your API's history.
Rule 1: every request says which version sent it
The cheapest thing you can do, and the one I would not ship a first release without, is to have every request carry the app version, build number and platform, usually as headers. It costs nothing on day one and it is impossible to add retroactively, because the versions already in the wild will never send it.
With it, questions like "which versions still call this endpoint?" or "can we remove this field yet?" have answers instead of guesses.
Rule 2: change the API by adding, not by editing
The core discipline is that the API only ever grows. In practice that means:
- Add fields, never rename them. If a field needs a better name, add the new one, keep sending the old one, and move clients over.
- Never change the meaning or type of a field. A score that was an integer stays an integer. If you need a decimal, it is a new field.
- New inputs need server-side defaults. If the new app sends a parameter the old app does not know exists, the server has to behave sensibly when it is missing.
- Remove only when nobody is left. A field gets deleted when the version data from Rule 1 says no supported version reads it, not when the newest app stops using it.
This is sometimes called expand and contract: expand the API so old and new clients both work, migrate clients, then contract once the old path is genuinely dead. The contract step is the one teams rush. On mobile it should be the slowest step you take.
I reach for a versioned endpoint, a /v2 of something, only when a change truly cannot be additive, because it means maintaining two code paths. The GraphQL dashboard work at Bettershop taught me the same thing on the web: a schema many consumers depend on rewards growing it carefully over redesigning it.
Rule 3: clients must tolerate what they do not understand
Additive changes only work if the clients already in the field can survive them. That has to be built into the very first version, because you cannot teach a shipped binary new manners.
Concretely, a well-behaved mobile client:
- Ignores fields it does not know. A strict parser that rejects unknown keys turns every harmless server addition into a crash for old versions.
- Handles unknown enum values. If the server adds a new session type or status, an old app should show a sensible fallback, not throw.
- Treats optional data as optional. Missing images, empty lists and null values get a placeholder, not a red screen.
Rule 4: the minimum-version gate ships in version one
The pattern I use is a small remote check that runs on launch. The app asks the server for two numbers: the minimum supported version and the recommended version. Below the recommended version, the app nudges the user to update and lets them carry on. Below the minimum, it blocks and sends them to the store.
This is your emergency brake, and it has the same catch as the version header: it only works for versions that contain it. If your first release ships without the gate, that release can never be forced to update, and you will be supporting it until the users leave on their own. Building it in from version one is a few hours of work that buys you years of options.
A hard block is a bad experience, so I keep it for real emergencies and rely on additive design for everything else.
Rule 5: remote flags so features can be switched off without a review
Since a shipped binary cannot be hot-fixed, the next best thing is being able to turn parts of it off remotely. New features go out behind a server-controlled flag, and the flag can be scoped by version and platform.
That turns a bad release from "wait for the next review and hope" into "switch the feature off now, fix it properly, and ship calmly". It also lets a feature land in a build before it is turned on, which takes the store review calendar out of your launch date. It is the same idea as treating a live agent's behaviour as something you change carefully and can roll back, which I wrote about in regression testing voice agents. Anything that is expensive to redeploy deserves a cheap off switch.
Rule 6: keep the logic on the server
The more decisions an app makes locally, the more versions of those decisions you end up running. If pricing rules, eligibility checks or ranking logic live in the client, a bug in them lives in every version that shipped with it, and the only fix is a release people may never install.
At RAQTS the clearest case was the live leaderboard. The backend owns the ordering, always, and every client, Unity, mobile or web, renders what the server says. One reason that rule mattered is consistency: three runtimes have to show the same standings. The other is compatibility. When ordering logic lives in one place, fixing it fixes it for every app version at once, including the ones that will never be updated.
The general rule: clients present, servers decide. Thin clients age far better than smart ones.
The extreme case: code inside someone else's app
At Geonode I built the Repocket C# SDK for Windows, Android and iOS. An SDK is the hardest version of this problem, because you do not even control the release of the app it lives in. The host app decides when to update its dependencies, and its users decide when to update the host app. Your code can be several layers removed from anyone who can push a fix.
That pushes every rule above further. The SDK has to identify its own version to the server, the protocol has to be tolerant in both directions, and anything that might need to change later has to be controllable from the server side. The same instinct shows up in the React Native VPN I built there, where native code the OS controls keeps running on its own terms. Software that runs out of your reach has to be designed assuming you will never get to touch it again.
Rule 7: test the old builds, not just the new one
Most teams test the new app against the new backend and stop there. The combination that actually breaks in production is the old app against the new backend.
So I keep recent production builds installable, which is much easier when the pipeline archives every release artefact, a habit I covered in building a Unity CI/CD pipeline. Before a backend change that touches a shared endpoint goes out, the oldest supported version gets a smoke test against staging: launch, sign in, the core flows, the screens that read the changed data. It takes minutes, and it is the only test that matches what your slowest-updating users will experience.
A checklist for your first mobile release
If you are about to ship a mobile app for the first time, these are the things that are cheap now and impossible later:
- Send app version, build number and platform on every request.
- Make the client ignore unknown fields and handle unknown enum values.
- Build the minimum-version and recommended-version check into launch.
- Put new features behind server-side flags.
- Keep business rules and rankings on the server.
- Archive every production build so you can test old versions later.
- Agree, as a team, that API changes are additive and removals wait for the data.
None of this shows up in a demo. It is the part of shipping to app stores that decides whether a backend deploy on a Tuesday afternoon is a non-event or a support queue full of people on last month's version. In my fractional CTO work, it is one of the first things I check on a mobile product, because it is far cheaper to set up before launch than to retrofit after.
I lead engineering as a fractional CTO and build cross-platform products across Unity, React Native, Next.js and AI agents, with release practices that survive real users. If you are about to ship a mobile app or inherit one, more about my background here, or book a call.