Skip to content

05
Writing

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.”

2026-06-13 · EN · 13 min · revised —

Illustrated cover with the title “Uploading Was Easy. Releasing Was the Hard Part.” and the line “What I learned when a mobile app release stopped being just my own responsibility.” A developer studies a board with a release switch between upload steps and release checks; a note reads “Uploading is a step. Releasing is a system.”

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.

Diagram: uploading is four steps (build, submit to the store, wait for review, approved build); releasing coordinates store availability, Remote Config state, platform readiness, build-number eligibility, real user impact, and monitoring and rollback. Footer: uploading gets a build into the store; releasing brings the right experience to the right users at the right time.

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.

Diagram of two release paths. The binary path runs from code change through build, store submission, review and user updates until the feature is available; the Remote Config path runs from a changed config value through a published template until behaviour changes for eligible users, faster but dependent on the team’s discipline.
“Remote Config does not remove release risk. It moves more of the responsibility inside the team.”

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.

Flowchart of a build-number gate: the user taps the menu, the app reads the Remote Config action, and checks whether the installed build number meets the required one. If yes, it opens the new native route; if not, it shows an update prompt or a fallback. Note: a remote value should not assume every installed app already has the new route.

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.

Table: version name versus build number. The user-facing version (versionName on Android, CFBundleShortVersionString on iOS, for example 2.3.0) is for store display and communication; the internal build identifier (versionCode, CFBundleVersion, for example 43) is for build ordering and feature gating.

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.

Comparison: an optional update lets the user continue, suits improvements, needs a later option and carries lower risk; a force update stops the user, is reserved for critical cases, must only start once the target build is available, and has higher user impact. Warning: never raise the minimum version before the new build is in the store.

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.

Diagram of nested circles of access: internal testers in the centre, then store reviewers, early-rollout users and public users, each reached through a different control (testing tracks, reviewer notes, Remote Config conditions, the build-number gate, monitoring).

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.

Timeline: Android and iOS move through submission, review and manual release at different times, each with its own Remote Config gate; a warning marks that global config should not be enabled before both platforms are ready.

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.

Checklist: Remote Config safe by default, staging isolated from production, build number confirmed, reviewer notes prepared, managed publishing configured, monitoring signals defined, rollback owner assigned; then a pipeline from pull request to Remote Config activation. Footer: CI/CD makes release faster, so the checklist has to move earlier.

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.

Eight-step release sequence: ship the binary quietly, wait for store availability, confirm build numbers, enable the Remote Config gate, start a limited rollout, monitor Crashlytics, Analytics and user signals, expand gradually, and roll back or pause if signals look wrong. Note: release is not one click, it is a sequence of gates.

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.

Illustration: a developer stands before a board with a release switch, its checks for build, store, config, monitoring and rollback lit; the caption beneath reads “Sometimes responsible engineering is not moving faster. It is knowing when to wait.”

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.

2026-06-13 · EN · 13 min · revised — · first published on Medium

Further reading: medium.com/@GuidoRefices