Skip to main content
Writing
7 min read

How to ship a new feature without breaking users on the old version

Every mobile release lives alongside older versions that are still installed. A version gate makes sure the new feature only shows up where it exists, and that the old app stays whole without everyone having to update in the same second.

  • Mobile
  • API Versioning
  • Feature Flag
  • Release

I'll start with the context, because this problem only becomes obvious after it bites you once.

When you have a website, deploying is simple: the new version goes up and everyone sees it on the next refresh. The old one stops existing. Mobile apps don't work that way. You publish the new build to the store, but people stay on the previous one for weeks, sometimes months. Updating is the user's decision, not yours. In other words: on the day you launch the new feature, there are several versions of your app running at the same time, all hitting the same API.

That is where the trap is. A new feature is almost never just a screen. It touches the backend: a new field in the payload, an endpoint that changed shape, a rule that now expects data the old version doesn't even know exists. If you treat "launching the feature" as "deploy the backend and publish the app", you have just assumed that everyone updated in the same second. They didn't.

The right question isn't "when do I turn it on", it's "where"

The idea behind a version gate is simple to state and easy to get wrong: the new feature can only appear where it actually exists. It isn't a flag that says "turn it on for everyone". It is a gate that asks, for every client that arrives, whether that client is able to go through.

And "is able to" has two halves that we tend to mix up:

  1. Is the user on a version of the app that knows how to render this?
  2. Should this user see it right now? (gradual rollout, plan, region, whatever it is)

The second half is the classic feature flag, the one almost everyone already knows. The first is the version gate, and it is the one that keeps the old app whole. They are different things, and treating the two as if they were the same is the root of half the release bugs in mobile.

The backend runs the gate

A common mistake is to leave the decision to the client alone: "if the app version is greater than or equal to X, show it". The problem is that when you need to hold the launch back, because a bug showed up or the rollout is being postponed, you can't. The rule is frozen inside builds that are already in people's hands.

That is why the decision lives in the backend, and the app only obeys. In practice, the client says who it is and the server answers what it may do:

// The app sends its own version on every request.
// Header: X-App-Version: 42

type Capabilities = {
  confirmarLeituraEscopo: boolean;
  chatInApp: boolean;
};

function capabilitiesFor(appVersion: number): Capabilities {
  return {
    // The scope-confirmation screen only exists from build 42 onwards.
    confirmarLeituraEscopo: appVersion >= 42,
    // Chat shipped in 45 but is still rolling out: the flag decides the "now".
    chatInApp: appVersion >= 45 && flags.isEnabled("chat"),
  };
}

The app, for its part, decides nothing about versions. It asks and draws what came back:

const caps = await api.getCapabilities();

if (caps.confirmarLeituraEscopo) {
  return <ConfirmacaoDeEscopo />;
}

return <FluxoDeAtendimentoAntigo />;

Notice the point that changes everything: the old version of the app does not have <ConfirmacaoDeEscopo />. It doesn't even know it exists. So for that version the gate isn't "hiding the new feature": it is just never receiving a true it wouldn't know what to do with. The backend guarantees that because it knows which version is asking.

The rule that sums it all up: the old app doesn't need the gate

This is the part I see people get backwards most often, so let me be very direct.

The gate exists in the new version, to protect the transition. In the old version it doesn't need to exist, and it shouldn't. The old version already is, by definition, the old behaviour. You don't go back to build 40 to add an if that hides a screen build 40 never had. That is energy spent on code that will die on its own once everyone updates.

In other words: you don't add a gate to the past. You add a gate in the present, aiming at the future. The new build knows how to live with a backend that may say "not yet". The old build only has to keep being itself.

The API contract is where this lives or dies

All of this falls apart if the backend breaks the shape of the payload assuming "everyone has already updated". The discipline is boring but short: a new field is always additive. You add; you never remove or rename while there is a live version that depends on the old format.

A practical example. If the new flow needs an escopo object, it comes in as optional:

{
  "id": 1234,
  "status": "a_caminho",
  "escopo": { "exigeConfirmacao": true, "texto": "Checar EPI antes de iniciar" }
}

The old version receives that same payload and simply ignores the field it doesn't know. Nobody breaks. And the list of things you never do while an old app is alive is short and non-negotiable: don't rename a field, don't remove a field, don't change its type, and, worst of all because it fails silently, don't change the meaning of a value while keeping its name.

And when additive isn't enough?

Sometimes the change is too big to fit in an optional field: the whole response needs a different shape. Then you don't touch the old endpoint. You create a new, versioned one and keep both alive at the same time:

GET /v1/atendimentos   → old format, still standing for whoever uses it
GET /v2/atendimentos   → new format, for the new apps

The detail a lot of people forget: shipping v2 doesn't give you the right to kill v1. v1 only dies when nobody hits it any more, and "nobody" here means no installed app, not no app in the store. Those are different things, which is why the last piece is measuring.

When the gate isn't enough: retiring a version for good

Holding compatibility has a cost. At some point the code fills up with version ifs, or a security problem appears in an old build, and you need that version to simply stop existing. That is what force update is for, and it has to be built in from the very first build, otherwise you never reach the people who are far behind.

The mechanics are the app asking, right at startup, what the floor is:

GET /app-config
{
  "minSupportedVersion": 40,
  "latestVersion": 47
}

And the app itself compares its build with that floor:

  • Below minSupportedVersion → a blocking "update to continue" screen. No way around it, because that version really doesn't run any more.
  • Between the floor and the latest → an optional nudge, such as a "there's a new version" banner, without blocking.

It is the kind of thing you put in the scaffold and hope never to use. But when you need it, it is the only way out that doesn't depend on the user's goodwill.

How I decide, and how I measure

Not every launch deserves this ceremony. The question I ask is a single one: if half my users don't update today, does anything break or look odd? If the answer is no, I changed a piece of copy or adjusted some spacing, ship it and move on. If the answer is yes, I touched the contract, the navigation or a critical flow, then there is a gate, there is a rollout, and the backend runs the gate.

And all of this only works if you know the version distribution of your user base. Since the app already sends X-App-Version on every request, that data comes for free: just log it and group it. With it in hand, decisions stop being guesses:

  • "Can I kill v1 of the endpoint?" → look at how many apps still hit it. 0.2%, dead for a year? Go ahead. 15%? Hold on.
  • "Which minSupportedVersion do I set?" → the number that retires as few people as possible to solve your problem.
  • "Did the feature land?" → cross the adoption of the feature with the adoption of the version that brought it.

Without that number, "retiring the old version" is a bet. With it, it is a decision.

Wrapping up

A version gate isn't sophistication. It is acknowledging a truth of mobile that the web lets us forget: you never control when the other side updates. You only control what it receives.

If I had to leave this in four sentences:

  1. The question is "where", not "when". The version gate asks whether the client knows how to render; the feature flag asks whether it should see it now. Those are two things.
  2. The backend runs the gate. The rule can't live in code that is already frozen in the user's hands.
  3. The old app doesn't need the gate. You add a gate in the present aiming at the future, never in the past.
  4. The contract only grows, and you measure who still uses it. Additive fields by default, /v2 when that isn't enough, force update when you need to retire a version for good, always looking at the version distribution.

None of this is complicated. The hard part is remembering, in the heat of a new feature, that the build from way back is still alive in someone's hands, and that it is your responsibility too. That is the real trick of versioning in mobile.

Have a system that needs to survive growth?

That's the kind of decision I help make. If it fits where you are, let's talk.