The App Version You Shipped in March Is Still Calling Your API
If you come from web, you have a deploy model so good you stop noticing it. You push, the CDN flips, and within minutes every user is running the same code. A bug fixed is a bug fixed for everyone. Mobile quietly takes that away. The build you ship today does not replace last month’s build. It joins it. Both keep calling the same backend, and so does the one from March, and so does whatever is sitting on a phone that has not opened your app since spring. I learned this the slow way as CTO at RAQTS, a sports-tech platform where 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 stopped being a task and became a permanent part of the job. Here is the model I work with now, and the seven rules that fall out of it. Old versions never really die None of the reasons are about code quality: Store review sets the calendar. A fix waits for review before anyone can install it, and a rejection pushes it back further. Phased rollouts are multi-version on purpose. A staged release on Play or a phased release on the App Store deliberately keeps most devices on the old build for a while. Users do not update when you do. Auto-update gets switched off, older phones cannot install your newest build, and some people open the app twice a month. Look at the version breakdown in your analytics a few weeks after any release and you will usually find several versions still active. Each was compiled against your API as it existed the day it shipped, and each will call it exactly that way until the user updates or deletes it. So the useful 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 point in your API’s history. Rule 1: every request says who sent it The one thing I would not ship a first release without: every request carries the app version, build number and platform. Something roughly like this in the shared API client: const headers = { “X-App-Version”: appVersion, // “2.4.1” “X-App-Build”: String(buildNumber), “X-App-Platform”: Platform.OS, // “ios” | “android” }; 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, “which versions still call this endpoint?” has an answer instead of a guess. Rule 2: the API only grows The core discipline is expand and contract, with the contract step taken slowly: Add fields, never rename them. Want a better name? Add the new field, keep sending the old one, move clients over. Never change a field’s type or meaning. An integer score stays an integer. If you need a decimal, that is a new field. New inputs need server-side defaults. The old app does not know the new parameter exists, so the server has to behave sensibly when it is missing. Remove only when nobody is left. A field is deleted when the version data from Rule 1 says no supported build reads it, not when the newest app stops using it. Web teams rush the contract step because on web it is safe. On mobile it should be the slowest thing you do. I only reach for a /v2 endpoint when a change genuinely cannot be additive, because it means maintaining two code paths for as long as the old clients live. Rule 3: clients tolerate what they do not understand Additive changes only work if the clients already shipped can survive them, and you cannot teach a shipped binary new manners. So version one has to be forgiving: Ignore unknown fields. A strict parser that rejects unexpected keys turns every harmless server addition into a crash on old builds. Handle unknown enum values. If the server adds a new status, an old app should show a fallback, not throw. Treat optional data as optional. Missing images, empty lists and nulls get a placeholder, not a red screen. The enum one is the sneakiest, because it looks correct in every code review. An illustrative shape: type SessionStatus = “scheduled” | “live” | “finished” | “unknown”; function parseStatus(raw: string): SessionStatus { return raw === “scheduled” || raw === “live” || raw === “finished” ? raw : “unknown”; // a status this build has never heard of } The exhaustive switch that your linter loves is exactly what blows up when the server learns a fifth value two releases from now. Rule 4: the minimum-version gate ships in version one On launch, the app asks the server for two numbers: a minimum supported version and a recommended version. Below recommended, nudge the user to update and let them carry on. Below minimum, block and send them to the store. This is your emergency brake, and it has the same catch as the version header: it only works for builds that contain it. If release one ships without the gate, release one can never be forced to update. You support it until its users leave on their own. A few hours of work in version one buys years of options. I keep the hard block for real emergencies. A forced update is a bad experience, and additive design should be doing the everyday work. Rule 5: remote flags, so you can switch things off without a review A shipped binary cannot be hot-fixed, so the next best thing is being able to turn parts of it off. New features ship behind a server-controlled flag that can be scoped by version and platform. That changes a bad release from “wait for the next review and hope” into “turn it off now, fix it properly, ship calmly”. Rule 6: clients present, servers decide Every decision an app makes locally is a decision you end up running in N versions. If pricing rules, eligibility checks or ranking logic live in the client, a bug in them lives in every build that shipped with it, and the only fix is a release some people will never install. At RAQTS the clearest case was the live leaderboard. The backend owns the ordering, always, and Unity, mobile and web all render what the server says. When the ordering logic lives in one place, fixing it fixes it for every app version at once, including the ones that will never update. 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 form of this problem, because you do not control the release of the app it lives in. The host app decides when to bump its dependencies, then its users decide when to update the host app. Your code can sit several layers away from anyone able to ship a fix. Every rule above gets pushed further. The SDK identifies its own version to the server, the protocol tolerates surprises in both directions, and anything that might need to change later is controllable server side. Rule 7: test the old builds, not just the new one Most teams test new app against new backend and stop. The pairing that actually breaks in production is old app against new backend. So I keep recent production builds installable, which is much easier when the CI pipeline archives every release artifact. Before a backend change touching a shared endpoint goes out, the oldest supported build gets a smoke test against staging: launch, sign in, core flows, and the screens that read the changed data. Ten minutes, and it is the only test that matches what your slowest-updating users will see. The cheap-now, impossible-later list If you are about to ship your first mobile app: Send app version, build number and platform on every request. Make the client ignore unknown fields and survive unknown enum values. Build the minimum 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 data. None of this shows up in a demo. It is the part 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. When I pick up a mobile product as a fractional CTO, it is one of the first things I check, because it is far cheaper before launch than after. The longer version, with the RAQTS and Geonode context, is on my site: the original post on nabeelbaghoor.com. If you have a story about a field rename that took out an old build, I would genuinely like to hear it in the comments.