You change a mobile backend safely by assuming that every app version you ever shipped can still call it. Make database changes in two phases (expand, then contract), keep API changes additive, enforce a minimum supported app version with a forced-upgrade path for the rare breaking change, gate new behavior behind feature flags, and test the server against recorded requests from old clients. A web backend can deploy its server and client together. A mobile backend cannot.
Why is a mobile backend harder to change than a web backend?
When you deploy a web app, every user gets the new front end on their next page load. When you release a mobile app, the new version reaches users gradually. Some update within days. Some have automatic updates turned off. Some devices are old and cannot run the latest version at all. Store review adds delay, and a phased rollout stretches the release over days more.
The result is a long tail. At any moment, your server may receive requests from the current version, the previous three, and a version from two years ago on a tablet in a drawer that someone just picked up. You cannot recall a shipped binary. Every request shape, every response field and every status code you ever exposed is a promise that lasts as long as the oldest installed version still matters to you.
This changes how you think about “breaking”. On the web, a breaking change is one you did not coordinate. On mobile, a breaking change is any change an old client cannot handle, and you cannot coordinate it at all. So the work shifts from fixing the old client to never breaking it.
What are expand and contract migrations?
Expand and contract (sometimes called parallel change) splits one risky change into several safe ones. The idea: add the new thing first, move over gradually, and remove the old thing last.
Consider renaming users.name to users.display_name. A single ALTER TABLE ... RENAME COLUMN breaks every server instance still running the old code during deploy, and every query that mentions the old name.
Do it in phases instead:
- Expand. Add the new column. Nothing reads it yet.
- Dual write. Update the server to write both columns. Backfill existing rows in batches.
- Switch reads. Update the server to read the new column, falling back to the old one if needed.
- Contract. Once nothing reads or writes the old column, and you have confirmed it, drop it in a later release.
-- Phase 1: expand (safe, additive)
ALTER TABLE users ADD COLUMN display_name text;
-- Phase 2: backfill in batches, not one giant statement
UPDATE users SET display_name = name
WHERE id IN (
SELECT id FROM users WHERE display_name IS NULL LIMIT 1000
);
-- Phase 4: contract (a later release, after verification)
ALTER TABLE users DROP COLUMN name;
Each phase is deployable on its own and reversible until the last one. The final drop is the only irreversible step, so make it deliberate and separate. The same pattern applies to changing a column type, splitting a table, or moving data between services.
A few PostgreSQL habits keep the expand phase cheap. Adding a nullable column without a default is a fast metadata change. Large backfills should run in batches so they do not hold long locks or flood replication. Adding a NOT NULL constraint, or building a large index, may need a careful approach for big tables, such as creating an index concurrently or adding a constraint in a not-yet-validated state and validating it afterward (ALTER TABLE). Check the PostgreSQL documentation for the version you run, since lock behavior has improved over time.
How do you change an API without breaking old apps?
Treat the API as an append-only contract. Rules that work well:
- Add fields, do not remove or rename them. Old clients ignore fields they do not know, if they parse leniently. New clients use the new fields.
- Never change the meaning of an existing field. If
pricewas an integer in cents, it stays that. Addprice_minor_unitsor a new object if the meaning must change. - Never narrow what you accept. A request that was valid yesterday must stay valid.
- Never widen what you promise without care. If a field was never null, do not start returning null. If an enum had three values, adding a fourth can crash a client that switches on it.
- Make new request fields optional with a default. Old clients do not send them.
Enums deserve a specific note. Many mobile clients decode enums strictly, so an unknown value throws. Teach clients to handle unknown values with a fallback case, and ship that fallback early, before you need it. Until it is widespread, treat adding an enum value as a potentially breaking change.
{
"id": "sub_123",
"status": "active",
"plan": "pro",
"display_name": "Pro",
"price_minor_units": 0
}
The example shows the habit: new fields appear next to old ones, and none is removed.
When additive change is not enough, you have two options. The first is a new endpoint or a new version prefix such as /v2/, which you run beside the old one. The second is content negotiation or an explicit version header. Both multiply your maintenance, because you now support two shapes. Prefer additive changes, and reserve a new version for changes that truly cannot be expressed additively. When you do add one, plan how and when the old one retires.
Generated clients help here. If you describe the API in a schema such as OpenAPI and generate types from it, a change that would break the shape becomes visible in a diff. Some teams run a schema comparison tool in continuous integration to flag removals and type changes automatically.
How do minimum supported versions and forced upgrades work?
Sometimes you must break compatibility: a security fix, a payment flow change, a data model that cannot support old shapes. You then need a way to tell old clients to update. The usual design:
- The app sends its version on every request, for example in a header such as
X-App-Version. - The server keeps a configured minimum supported version per platform.
- If the app’s version is below the minimum, the server returns a specific, documented response, for example a
400or403with a machine-readable code such asapp_version_unsupported. Some teams use426 Upgrade Required, but HTTP defines that status for protocol upgrades. - The app, on seeing that response, shows a blocking screen with a link to the store.
The critical constraint is that the forced-upgrade handling must exist in the app before you need it. A version that does not know how to handle the response will just show a generic error. So ship this mechanism in your first release, and never remove it.
Distinguish a hard minimum from a soft one. A hard minimum blocks the app. A soft recommendation shows a dismissible prompt that says an update is available. Use the soft one for nudging and keep the hard one for real breakage, since forcing users to update interrupts them and can cost you users.
Before raising the minimum, look at real data: what share of active requests come from versions below the new minimum? That is a business decision as much as a technical one. Log the app version on requests so you can answer the question.
How do feature flags help?
A feature flag lets you ship code in a disabled state and turn it on later, per user, per percentage or per app version. For migrations, flags decouple deploying from releasing. You can deploy the new read path, enable it for internal users, then for one percent, then for everyone, and roll back by flipping the flag without a deploy or a store release.
Flags also help on the client. A new screen can ship in the binary but stay hidden until the backend that supports it is ready. When the backend is ready, the server enables the flag for versions that include the screen, and old versions never see it.
Keep flags disciplined:
- Give each flag an owner and a removal date. Old flags are a source of untested code paths.
- Evaluate flags on the server where you can, so a client cannot enable something just by editing local state.
- Make the default safe. If the flag service is unreachable, the app should fall back to the stable behavior.
- Remove the flag and the dead branch once the rollout is finished.
How do you test against old clients?
The reliable approach is to test against what old clients actually send. Several practices help:
- Record real requests. Keep a set of request and response fixtures for each shipped app version, with personal data removed. Replay them against the current server in continuous integration and assert the responses still parse.
- Run contract tests. For each supported version, check that the fields the client relies on still exist with the same types.
- Keep an old build. Archive installable builds of old versions, and run a small set of smoke tests against staging before a release that touches the API.
- Test migrations on realistic data. A migration that is fast on a thousand rows can lock a table with a hundred million. Test on a copy with representative volume.
- Test the in-between states. During an expand and contract rollout, both old and new server code may run at once. Run your tests against each combination that can occur in a deploy.
Do not rely only on the latest version in your test suite. Your most recent build is the least likely to break.
When is it safe to remove old behavior?
Define a support policy in advance. For example, you support the current version and the previous two major versions, and you announce deprecations well ahead of time. Then measure. Track requests per app version, and keep an eye on the share of traffic that depends on each old behavior. Remove something only when the numbers say the cost of keeping it exceeds the cost of losing those users, and when you have shown the upgrade prompt for long enough.
Add deprecation signals to the API itself. A response header or a field that marks a feature as deprecated gives you something to log and alert on. When a deprecated path stops receiving traffic, you can remove it with confidence.
Key takeaways
- Assume every app version you have ever shipped can call your server.
- Split risky database changes into expand, dual write, switch reads and contract, and make the final drop a separate, deliberate release.
- Keep API changes additive. Never remove, rename or change the meaning of a field, and make clients tolerate unknown enum values.
- Ship the forced-upgrade mechanism in the first release, and use a hard minimum version only for real breakage.
- Use feature flags to separate deploy from release, with owners and removal dates.
- Test against recorded requests from old clients, not only the latest build.
How does this relate to Edict?
Edict is a mobile backend in development. Its planned database module is designed to compile declared entities into PostgreSQL tables, and its planned business logic layer is designed to keep rules such as minimum versions and limits in configuration. Edict is being built with schema evolution and migration planning in mind, so that backend changes can be tested locally against compatible clients before a deployment. That tooling is in development, and the practices above apply to any backend today.
Apps whose users keep records for years, such as budgeting and expense tracking, feel this problem first. For the database side, read Postgres row-level security for mobile app backends.