SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:35
UP00:01
MOD · ARTICLE · BEST PRACTICESS/N · AX-ANDROIPUBLISHED
All Articles
Best Practices · Updated 11 MIN

Feature Flags in Android: Ship Faster with Remote Config and Gradual Rollouts

Implement a production-grade feature flag system using Firebase Remote Config, local overrides, and gradual rollouts to de-risk Android releases.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why does an Android team need feature flags?

A mobile release cannot be taken back. Once a build is on a phone it stays there until the user updates, and a fix has to go through build, review and rollout before anyone receives it. A feature flag separates two things that a release ties together: shipping the code and switching it on. That gives a team three abilities: - A kill switch. A feature that misbehaves in production is turned off from a console, without waiting for a new build to reach users. - Merging unfinished work. Code can land on the main branch behind a flag that is off, which keeps branches short. - Exposure in steps. A feature can be enabled for a fraction of users first. This is a different lever from a Play staged rollout, which controls who receives a build. A flag controls what that build does. The cost is real as well. Every flag is a branch in the code and a second configuration to test, and a flag nobody removes is permanent complexity. The last section is about that. The earlier version of this article quoted figures for merge conflicts, rollback times and flag counts. None of them had a source, so they are gone. Its Remote Config sample also would not compile. Both are corrected here.

Architecture: The Feature Flag Layer

Put one interface between the app and the flag provider. Screens and ViewModels ask whether a flag is on. They never import the Firebase SDK, and they do not know where the answer came from. That boundary pays for itself three ways: tests supply a fake without a network, QA builds can layer local overrides on top, and the provider can be replaced without touching a screen. Define the flags as a closed set, so that listing every flag is a property of the type and needs no reflection. A sealed type or an enum both work. The sample uses an enum because every flag has the same shape. Each flag carries its default, its owner and the date by which it should be gone.
kotlin
// The interface the rest of the app depends on
interface FeatureFlags {
    fun isEnabled(flag: Flag): Boolean
}

// A closed set: Flag.entries lists every flag, no reflection needed
enum class Flag(
    val key: String,
    val defaultValue: Boolean,
    val owner: String,
    val removeBy: String?  // ISO date, or null for a permanent switch
) {
    NEW_CHECKOUT_FLOW(
        key = "new_checkout_flow",
        defaultValue = false,
        owner = "payments-team",
        removeBy = "2027-01-15"
    ),
    AI_RECOMMENDATIONS(
        key = "ai_recommendations",
        defaultValue = false,
        owner = "ml-team",
        removeBy = "2027-02-01"
    ),
    PAYMENTS_KILL_SWITCH(
        key = "payments_enabled",
        defaultValue = true,
        owner = "payments-team",
        removeBy = null
    ),
}

// A fake for tests
class FakeFeatureFlags(
    private val enabled: Set<Flag> = emptySet()
) : FeatureFlags {
    override fun isEnabled(flag: Flag) = flag in enabled
}

Firebase Remote Config Implementation

Firebase Remote Config "is a cloud service that lets you change the behavior and appearance of your client app or server without requiring users to download an app update." The model is defaults first: "you create in-app default values that control the behavior and appearance of your application", then override them from the console. The decision that matters is when a fetched value takes effect. Firebase's loading strategies guide describes three options and is direct about the first one, fetching and activating as the app starts: "Avoid this approach in any situation where your UI could change noticeably while users are using it." A flag that swaps a checkout flow is exactly that situation. For structural flags, use the strategy the guide calls effective: "load new configuration values to activate on your app's next startup." Activate what was fetched last time, then fetch for next time. The user sees one consistent app per session. Real-time updates exist for the cases that cannot wait, such as a kill switch. Two details of that API were wrong in the earlier sample: - ConfigUpdateListener has two methods, onUpdate and onError (reference). It cannot be written as a single lambda. - The listener is not removed for you. addOnConfigUpdateListener returns a registration, and remove() is documented as "Removes the listener being tracked by this ConfigUpdateListenerRegistration" (reference). The old sample left a comment claiming it was automatic.
kotlin
class FirebaseFeatureFlags(
    private val remoteConfig: FirebaseRemoteConfig
) : FeatureFlags {

    // Call once at startup, before the first screen reads a flag.
    fun start() {
        remoteConfig.setDefaultsAsync(
            Flag.entries.associate { it.key to it.defaultValue }
        )
        // Strategy 3: use what the last session fetched...
        remoteConfig.activate()
        // ...and fetch for the next one. Nothing changes mid-session.
        remoteConfig.fetch()
    }

    override fun isEnabled(flag: Flag): Boolean =
        remoteConfig.getBoolean(flag.key)

    // Real-time, for the few flags that cannot wait for a restart.
    fun killSwitch(flag: Flag): Flow<Boolean> = callbackFlow {
        trySend(isEnabled(flag))

        val listener = object : ConfigUpdateListener {
            override fun onUpdate(configUpdate: ConfigUpdate) {
                if (flag.key in configUpdate.updatedKeys) {
                    remoteConfig.activate().addOnCompleteListener {
                        trySend(isEnabled(flag))
                    }
                }
            }

            override fun onError(error: FirebaseRemoteConfigException) {
                close(error)
            }
        }
        val registration = remoteConfig.addOnConfigUpdateListener(listener)

        awaitClose { registration.remove() }
    }
}

Using Feature Flags in Compose UI

Read a flag in the ViewModel, not in the composable. The composable receives a plain value and renders one branch. That keeps the flag out of previews and UI tests, which pass the value they want. Read a structural flag once, when the ViewModel is created. Because the provider activates on startup, the value is stable for the session, and reading it once makes that explicit: a user who is halfway through the old checkout is not moved to the new one. The kill switch is the exception. It is collected as state so that the screen reacts while it is open. Whether to expose that as its own stream or fold it into one screen state is the question covered in MVVM vs MVI.
kotlin
@HiltViewModel
class CheckoutViewModel @Inject constructor(
    featureFlags: FeatureFlags
) : ViewModel() {

    // Read once: stable for the life of this screen
    val useNewFlow: Boolean =
        featureFlags.isEnabled(Flag.NEW_CHECKOUT_FLOW)
}

@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel = hiltViewModel()) {
    CheckoutContent(
        useNewFlow = viewModel.useNewFlow,
        onComplete = viewModel::onCheckoutComplete
    )
}

// The stateless composable takes the value, not the flag system
@Composable
fun CheckoutContent(useNewFlow: Boolean, onComplete: () -> Unit) {
    if (useNewFlow) {
        NewCheckoutFlow(onComplete = onComplete)
    } else {
        LegacyCheckoutFlow(onComplete = onComplete)
    }
}

Debug Overrides and QA Tools

Testers need to force a flag on or off without touching the production console. Wrap the real provider in a debug one that checks local overrides first, and install it only in debug builds. The earlier sample read the override store with runBlocking inside isEnabled, which blocks the calling thread on disk I/O every time a flag is read, including on the main thread. Keep the overrides in memory, load them once, and write them through to storage in the background. A DataStore is a reasonable place to persist them.
kotlin
// Debug builds only: local overrides win over the real provider
class DebugFeatureFlags(
    private val real: FeatureFlags,
    initialOverrides: Map<Flag, Boolean> = emptyMap()
) : FeatureFlags {

    private val overrides = MutableStateFlow(initialOverrides)

    override fun isEnabled(flag: Flag): Boolean =
        overrides.value[flag] ?: real.isEnabled(flag)

    fun setOverride(flag: Flag, value: Boolean) {
        overrides.update { it + (flag to value) }
    }

    fun clearOverride(flag: Flag) {
        overrides.update { it - flag }
    }

    fun clearAll() {
        overrides.value = emptyMap()
    }

    // Collect this to persist changes off the main thread
    val changes: StateFlow<Map<Flag, Boolean>> = overrides.asStateFlow()
}

Flag Hygiene: Expiration and Cleanup

A flag that is never removed is a branch that is never deleted. The practices below are ours, from running flags in production. They are conventions, not a standard. 1. Every temporary flag has an owner and a removal date. They live on the flag definition, as in the enum above. A flag with no date is a permanent switch and should be named like one. 2. A test fails when a flag is overdue. A plain unit test that walks Flag.entries and compares removeBy with today's date is enough. The failure message names the owner. 3. Removal is real work. Delete the definition, delete the losing branch, delete the tests for it, and remove the parameter from the console. Plan it like any other change. 4. Test both sides while the flag lives. A flag doubles the paths through the code it guards. Run the tests for that code with the flag on and with it off. Run the overdue check in the same CI pipeline as everything else, so that an expired flag is a red build and a decision, not a surprise a year later.
kotlin
class FlagHygieneTest {

    @Test
    fun `no temporary flag is past its removal date`() {
        val today = LocalDate.now()
        val overdue = Flag.entries.filter { flag ->
            flag.removeBy?.let { LocalDate.parse(it) < today } ?: false
        }

        assertTrue(
            "Overdue flags: " +
                overdue.joinToString { it.key + " (" + it.owner + ")" },
            overdue.isEmpty()
        )
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1A flag separates shipping code from switching it on. A Play staged rollout controls who gets a build; a flag controls what the build does.
  2. 2Hide the provider behind one interface so screens, tests and QA overrides never touch the Firebase SDK.
  3. 3Firebase advises against activating new values where the UI could change noticeably mid-use. Activate structural flags on the next startup.
  4. 4ConfigUpdateListener has onUpdate and onError, and the listener must be removed through its registration.
  5. 5Read a structural flag once in the ViewModel and pass a plain value to the composable.
  6. 6Give every temporary flag an owner and a removal date, and fail a test when the date passes.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Should I use feature flags for everything?

No. Use them where being able to switch something off without a release is worth an extra code path: risky features, staged exposure and kill switches for things that depend on a backend. Every flag doubles the paths through the code it guards, so a stable feature is better shipped with no flag at all.

When should a Remote Config value take effect?

For anything that changes the structure of a screen, on the next app start. Firebase's loading guide warns against activating values where the UI could change noticeably while someone is using it. Activate the previously fetched values at startup and fetch new ones for the following session. Reserve real-time updates for kill switches.

How do I test feature flags?

Depend on an interface and pass a fake in unit tests, so no test needs the network or the Firebase SDK. Run the tests for flagged code with the flag on and off. In debug builds, wrap the provider with local overrides so testers can force either state without changing the production configuration.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA