Uploading Was Easy. Releasing Was the Hard Part.
A practical reflection for developers who are moving from “I can upload the app myself” to “I need to release safely for real users.”

What I learned when a mobile app release stopped being just my own responsibility.
For a long time, I thought releasing an app was mostly about getting a build into the store.
Build the APK. Archive the iOS app. Upload it. Wait for review. Ship.
That mental model worked when the app was small, the users were limited, and the blast radius was low. When I worked independently, the loop felt short and controllable.
Build. Upload. Fix. Repeat.
But that confidence changes when the app is no longer just “your app.”
It changes when real users depend on it. It changes when the app contains sensitive flows. It changes when Android and iOS do not move at the same speed. It changes when a remote value can affect users who never installed your latest build.
That was when I learned something I should have understood earlier:
Uploading a mobile app is not the same as releasing a feature.
They only look similar from the outside.

The old way: release means upload
As an independent developer, my release process used to be mostly linear.
Finish the feature. Test it. Build the app. Upload it.
The store felt like the final gate. If Google Play accepted the build, the release was done. If App Store review passed, the release was done.
That model can work when the app is small enough, the users are limited, and the blast radius is low.
But in a real production environment, the store is only one layer of the release.
There are users who already have older versions installed. There are features controlled remotely. There are testers and reviewers who need access before the public does. There are release notes, privacy labels, data safety forms, staged rollouts, crash monitoring, internal approvals, and rollback decisions.
And then there is Remote Config.
At first, Remote Config felt like a helper.
Later, I realized it was also a release surface.
That distinction changed everything for me.
Remote Config is not just configuration
Firebase Remote Config is designed for the kind of flexibility developers love. Firebase describes it as a way to change an app’s behavior and appearance without requiring users to download a new app update.
That is powerful.
It means you can hide a feature. Change a menu. Control a maintenance state. Route a user to a native screen or keep them on a safer fallback flow. Release something gradually. Target specific conditions. Adjust behavior without waiting for every user to install a new binary.
It feels like a switch.
But a switch connected to production users — across multiple installed versions, without another store review — is not a small thing.
The mistake is thinking Remote Config is “lighter” than a release.
Technically, it is lighter.
Operationally, it can be more dangerous.
A binary release goes through the store. Remote Config does not go through that same review gate. A binary release takes time. Remote Config can affect users much faster, depending on how the app fetches and activates values. A binary release is reviewed externally. Remote Config depends much more on the discipline of the team that publishes the value.
Martin Fowler’s writing on feature toggles gave me better language for what was actually happening. A feature can already exist in the codebase, even in production, but remain latent until a toggle enables it. Fowler distinguishes between the code being deployed and the feature being released.
That was the part I had missed.
The toggle is not just a technical detail.
The toggle becomes part of the release strategy.
And if a toggle becomes part of the release strategy, it also needs an exit plan. Once the rollout is stable and the feature becomes the default behavior, the flag should either be removed or clearly documented as a long-term operational flag. Otherwise, every old toggle becomes another branch of complexity.
Remote Config was no longer just a configuration layer. It was part of the deployment and release system. And if it is part of the release system, it needs the same care as code.

The hidden problem: old users do not magically get new routes
One of the most important lessons came from a simple question:
What happens if Remote Config points users to a native route that only exists in the newest app version?
At first, it sounds obvious.
New feature. New route. Update the config. Done.
But production users do not all update at the same time.
Some users are on the latest version. Some users are one version behind. Some users may not update for weeks. Some users may only open the app when they urgently need it.
If Remote Config changes a menu action from an old safe flow to a new native route, older binaries may not know that route exists.
The result can be a crash, a blank screen, or a broken journey — at exactly the moment someone needed the service most.
This is where build-number gating became important.
Before opening certain features, the app needs to ask a boring but important question:
Is this user’s build new enough to support this route?
If yes, continue.
If no, keep the user on a fallback flow or guide them to update.
That small check changed the whole release model.
The feature is not released simply because the code exists.
The feature is released only when the binary, the store, the Remote Config value, and the user’s installed version are all aligned.

versionName and build number are not the same thing
Another lesson came from something that looks harmless: versioning.
Most users see something like 2.3.0.
That is the version name — the human-readable label in the store.
But internally, the platform also tracks a build number or version code.
Android’s versioning documentation makes this distinction explicit. versionName is the release version shown to users. versionCode is an internal integer used by Android and publishing systems to determine version order. The versionCode should monotonically increase with each release and should not be treated as the user-facing version.
Apple has a conceptually similar separation, even though the exact fields and rules are different. CFBundleShortVersionString is the user-facing release version, while CFBundleVersion identifies the build iteration.
The version name is for humans.
The build number is for systems.
For a global update prompt, the version name can make sense. The app can compare the current version label with the latest published version and decide whether to show an optional update prompt.
But for feature eligibility, the build number is safer.
A feature does not care whether the store label says 2.3.0.
It cares whether the installed binary actually contains the route, screen, dependency, permission, or native capability required to run that feature.
That distinction sounds boring.
But boring distinctions are often where production bugs hide.

Optional update is not force update
I also learned to stop treating all update prompts as the same thing.
An optional update is a nudge.
It says:
There is a newer version. You should update when you can.
The user can continue. The app does not block them.
A snooze mechanism matters here. Users who feel harassed by repeated dialogs will lose trust in the product, and that is also a product failure.
A force update is different.
It says:
You cannot continue until you update.
That should be rare.
A force update is not a growth tactic. It is not a shortcut to push adoption. It is not a way to make everyone use the latest UI.
It should be reserved for cases where older versions are no longer safe: security issues, breaking API changes, or critical flows that cannot continue reliably.
The most dangerous part of force update is timing.
If the minimum required version is raised before the target binary is actually available in the store, users can get trapped.
They open the app. The app tells them to update. They go to the store. The store does not have the new version yet.
That is not just a UI bug.
That is a release strategy failure.
And for the user, it does not matter which layer failed. They only know the service is not working.

Store reviewers are a special kind of user
Before this experience, I thought of store review as a waiting room.
Submit the build. Wait for Apple or Google. Fix issues if rejected.
But store reviewers create a unique release problem:
They need to see the new feature to approve it, while the public should not necessarily see that feature yet.
If the feature is hidden too aggressively, reviewers cannot verify it.
If the feature is opened too early, real users may access something unfinished.
Apple’s TestFlight helps teams distribute builds to internal and external testers before a release goes public on the App Store. Apple documents that external testing can support up to 10,000 testers. Google Play also separates testing and production through internal, closed, open, and production tracks.
But tools alone do not solve the strategy.
Internal testing tracks, TestFlight groups, reviewer notes, Remote Config conditions, and build gates all need to work together.
This is where I started seeing release as a system.
Not a button.
Not a build artifact.
Not a checklist.
A system.

Android and iOS do not move at the same speed
Another thing that looks simple on paper but becomes risky in production: Android and iOS approvals do not always happen at the same time.
Android may be ready first.
iOS may still be in review.
Or the opposite.
If Remote Config is global and the feature is enabled for everyone, one platform may be ready while the other is not.
That creates an asymmetric release problem.
Both stores provide mechanisms to help with this.
Google Play supports staged rollouts, where an update reaches only a percentage of users and can be increased over time. Google Play also provides publishing controls so teams can decide when certain app changes are sent for review and published.
App Store Connect offers manual release, where an approved build can wait in a Pending Developer Release state until the team decides to release it. Apple also offers phased release, which gradually rolls out an update over seven days to eligible users, while still allowing users to manually download the update from the App Store.
These controls exist for a reason.
But having them does not automatically make the release safe.
You still need to know when to use them — and why.
The safer approach was to control the Remote Config gate per platform.
When Android is live, activate the Android gate.
When iOS is live, activate the iOS gate.
Until then, keep the other platform on the safer fallback flow.
This is not elegant in the way code architecture is elegant.
But production is not always about elegance.
Sometimes it is about preventing one group of users from being stranded just because another platform finished review earlier.

CI/CD made speed easier, which meant our release discipline had to become more explicit.
CI/CD helped automate the build and upload process.
That is good.
Tools like Codemagic can publish Flutter apps to Google Play tracks and App Store Connect/TestFlight when the required signing and publishing setup is configured. That removes a lot of manual work and reduces many repetitive mistakes.
But automation also removes friction.
A merge can trigger a build.
A build can upload to a store track.
A store submission can move faster than the team’s coordination process.
The more automated the pipeline becomes, the more important the pre-release checklist becomes.
Before merging, the team needs to confirm:
Is Remote Config in a safe state?
Are staging values isolated from production?
Is the feature hidden by default?
Is the build number final?
Is the store release set to manual when coordination is needed?
Are reviewer notes ready?
Who owns the rollback decision?
Automation does not remove responsibility.
It moves responsibility earlier — into the pull request, the review, the checklist, and the conversation before the merge.

The release strategy that finally made sense
After going through the confusion, the safer model became clear.
Ship the binary quietly first.
Do not expose the feature immediately.
Wait until the build is available in the right store channel.
Confirm the final build numbers from the store consoles.
Only then update Remote Config.
The rule became simple:
Update Remote Config only after the binary is available in the store.
Not before.
Not simultaneously.
After.
Firebase Remote Config Rollouts made this mindset more concrete.
A rollout is not just “turning something on.” It is a controlled exposure of a configuration change to a percentage of users. With Crashlytics and Analytics signals, the team can observe stability and behavior before expanding the rollout further.
In a more mature setup, this decision should not depend only on feeling. The team should define the signals before rollout: crash-free users, fatal crash rate, key event completion, backend error rate, and user complaints. The exact threshold depends on the product, but the principle is the same: do not expand exposure without signals.
That was the mindset shift I needed.
Not:
Is the feature done?
But:
Who should safely experience this first, and what signals tell us it is safe to continue?
This flow is slower than “merge and hope.”
But it is safer.
And when the app serves real users, safer is not bureaucracy.
Safer is respect.

The biggest mindset shift
The biggest lesson was not about Firebase.
It was not about CI/CD.
It was not about App Store Connect.
The biggest lesson was realizing that release engineering is product thinking.
A release strategy answers product questions:
Who should see this feature first?
Who should not see it yet?
What happens to old users?
What happens if store review is delayed?
What happens if the config is wrong?
What happens if we need to roll back?
These are not only engineering questions.
They are user experience questions.
Because a broken release is not just a failed deployment.
For the user, it is a failed service.
What I would tell my earlier self
Do not treat Remote Config as a shortcut.
Do not assume the latest binary is already on everyone’s phone.
Do not use the public version name and the internal build number interchangeably.
Do not activate force update unless the target version is already available in the store.
Do not rely on memory for release steps.
Do not be ashamed of checklists.
When I was working alone, speed felt like strength.
But in a real production system, strength looks different.
Strength is being able to pause.
Strength is knowing when not to publish.
Strength is designing a release so that one mistake does not reach everyone.
What I would do differently next time
For releases that involve Remote Config gates, platform coordination, or sensitive user flows, I would make managed publishing and manual release the default unless the team already has strong automated release guardrails.
I would never set a Remote Config gate value without first reading the actual build number from the store console. Guessing or estimating build numbers is where mistakes begin. The console always knows. Fetch it there.
I would treat staging Remote Config conditions as structurally isolated from production. For that kind of separation, App ID conditions are usually safer than percentage-based targeting. But even then, condition order, default values, and the final published template still need to be reviewed before release.
I would write the rollback plan before the release plan.
The question is not only:
How do we ship this?
The question is also:
How do we undo this quickly and clearly if it goes wrong?
If the team cannot answer that without improvising under pressure, the release is not ready yet.
I still love the feeling of shipping.
But now I respect the quiet work that happens before shipping: the gates, the fallbacks, the reviewer notes, the staged rollout, the monitoring, the boring checks, and the decision to wait.
Because releasing software is not only about getting new code into users’ hands.
It is about making sure the right users get the right experience at the right time.
And sometimes, the most important release decision is not when to move fast.
It is when to hold the switch.

Further reading
These references shaped the technical framing of this reflection:
- Firebase Remote Config documentation — for the idea that app behavior can be changed without requiring a binary update.
- Firebase Remote Config Rollouts — for controlled, percentage-based exposure with Crashlytics and Analytics signals.
- Martin Fowler: Feature Toggles — for the language of latent code, release toggles, and the operational cost of feature flags.
- Android Developers: Version your app — for the distinction between versionName and versionCode.
- Apple App Store Connect documentation — for manual release, Pending Developer Release, phased release, and TestFlight.
- Google Play Console documentation — for staged rollouts and publishing controls.
- Codemagic documentation — for Flutter CI/CD publishing workflows to Google Play and App Store Connect.