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

Implement a production-grade feature flag system using Firebase Remote Config and percentage-based rollouts.

Introduction

Feature flags decouple deployment from release. They enable gradual rollouts, A/B testing, and kill switches. This guide builds production feature flag systems with Firebase Remote Config, local overrides, and percentage-based targeting.

Feature Flag Architecture

Centralize flag access through FeatureFlag object. Define flags as enum or sealed class. Provide defaults for offline. Cache fetched values. Log flag states for debugging.

Firebase Remote Config Integration

Add Firebase Remote Config SDK. Set fetch interval for development vs production. Use conditions for targeting. Handle fetch failures gracefully. Set default values in app.

Local Overrides for Development

Implement local override system for debug builds. Use buildConfig or local JSON file. Override via developer settings. Never ship override code in release.

Percentage Rollouts

Use Remote Config conditions for percentage targeting. Segment by app version, country, or user properties. Monitor crash rates per segment. Be prepared to rollback quickly.

Frequently Asked Questions

Should I use feature flags for everything?

No. Use flags for risky features, A/B tests, and gradual rollouts. Don't use flags for stable features - they add complexity. Clean up old flags after full rollout.

How do I test feature flags?

Test with all flag combinations. Use local overrides in development. Test flag fetch and cache behavior. Include flag states in crash reports.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:37
UP00:01
MOD · ARTICLE · BEST PRACTICESS/N · AX-ANDROIPUBLISHED
Best PracticesMar 14, 202613 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 percentage-based rollouts to de-risk Android releases.

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

Why Every Android Team Needs Feature Flags

Feature flags decouple deployment from release. You ship code to the Play Store without activating it, then turn features on for specific user segments, percentages, or markets -- all without a new APK. This single capability transforms how Android teams operate: - **Reduce rollback time from days to seconds**: A bad feature in a Play Store update requires a new build, review, and staged rollout (3-7 days). A feature flag can be turned off in under 30 seconds. - **Enable trunk-based development**: Developers merge incomplete features behind flags instead of maintaining long-lived feature branches. Teams using feature flags report 62% fewer merge conflicts and 3x faster integration cycles. - **Power A/B testing**: Show different experiences to different user cohorts and measure impact on engagement, conversion, and retention before committing to a design. - **Gradual rollouts**: Start at 1% of users, monitor crash rates and performance metrics, then ramp to 5%, 25%, 50%, 100%. If problems appear at any stage, halt instantly. The trade-off is complexity: feature flags add conditional branches to your code, and stale flags that are never cleaned up create technical debt. A disciplined approach -- with expiration dates, owner assignments, and automated cleanup -- makes the benefits far outweigh the costs.

Architecture: The Feature Flag Layer

A well-designed feature flag system has three layers: a remote source (Firebase Remote Config, LaunchDarkly, or your own backend), a local cache with fallback defaults, and a typed API that the rest of your app consumes. The app code should never know *where* the flag value comes from -- only what the flag's current value is. This abstraction lets you swap remote providers, add local overrides for QA testing, and unit test flag-dependent behavior without network calls.
kotlin
// Domain layer: the interface your app depends on
interface FeatureFlags {
    fun isEnabled(flag: Flag): Boolean
    fun getString(flag: StringFlag): String
    fun getInt(flag: IntFlag): Int
    fun observe(flag: Flag): Flow<Boolean>
}

// Type-safe flag definitions
sealed class Flag(
    val key: String,
    val defaultValue: Boolean,
    val owner: String,         // Team or person responsible
    val expiresAt: String?     // ISO date -- when to clean up
) {
    data object NewCheckoutFlow : Flag(
        key = "new_checkout_flow",
        defaultValue = false,
        owner = "payments-team",
        expiresAt = "2026-06-01"
    )
    data object ComposeNavigation : Flag(
        key = "compose_navigation",
        defaultValue = false,
        owner = "platform-team",
        expiresAt = "2026-05-15"
    )
    data object DarkModeV2 : Flag(
        key = "dark_mode_v2",
        defaultValue = false,
        owner = "design-team",
        expiresAt = null // Permanent flag
    )
    data object AiRecommendations : Flag(
        key = "ai_recommendations",
        defaultValue = false,
        owner = "ml-team",
        expiresAt = "2026-07-01"
    )
}

sealed class StringFlag(
    val key: String,
    val defaultValue: String
) {
    data object CheckoutButtonLabel : StringFlag(
        key = "checkout_button_label",
        defaultValue = "Place Order"
    )
}

sealed class IntFlag(val key: String, val defaultValue: Int) {
    data object MaxCartItems : IntFlag(
        key = "max_cart_items",
        defaultValue = 50
    )
}

Firebase Remote Config Implementation

Firebase Remote Config is the most common remote source for Android feature flags. It supports percentage-based rollouts, user property targeting, and A/B testing through Firebase A/B Testing integration. The implementation below wraps Remote Config behind the `FeatureFlags` interface, with a local DataStore cache for offline access and sub-millisecond reads.
kotlin
class FirebaseFeatureFlagProvider(
    private val remoteConfig: FirebaseRemoteConfig,
    private val localCache: DataStore<Preferences>,
    private val analytics: Analytics
) : FeatureFlags {

    init {
        // Set defaults from flag definitions
        val defaults = Flag::class.sealedSubclasses.associate { subclass ->
            val flag = subclass.objectInstance!!
            flag.key to flag.defaultValue
        }
        remoteConfig.setDefaultsAsync(defaults)

        // Fetch with 1-hour cache for production, 0 for debug
        val cacheExpiration = if (BuildConfig.DEBUG) 0L else 3600L
        remoteConfig.fetchAndActivate()
    }

    override fun isEnabled(flag: Flag): Boolean {
        val value = remoteConfig.getBoolean(flag.key)
        // Track flag evaluation for analytics
        analytics.logFlagEvaluation(flag.key, value)
        return value
    }

    override fun getString(flag: StringFlag): String {
        return remoteConfig.getString(flag.key).ifEmpty { flag.defaultValue }
    }

    override fun getInt(flag: IntFlag): Int {
        return remoteConfig.getLong(flag.key).toInt()
    }

    override fun observe(flag: Flag): Flow<Boolean> = callbackFlow {
        // Emit current value immediately
        trySend(isEnabled(flag))

        // Listen for remote config updates
        val listener = ConfigUpdateListener { configUpdate ->
            if (configUpdate.updatedKeys.contains(flag.key)) {
                remoteConfig.activate().addOnCompleteListener {
                    trySend(isEnabled(flag))
                }
            }
        }
        remoteConfig.addOnConfigUpdateListener(listener)

        awaitClose { /* listener auto-removed */ }
    }
}

// Hilt module for DI
@Module
@InstallIn(SingletonComponent::class)
object FeatureFlagModule {
    @Provides
    @Singleton
    fun provideFeatureFlags(
        remoteConfig: FirebaseRemoteConfig,
        @ApplicationContext context: Context,
        analytics: Analytics
    ): FeatureFlags = FirebaseFeatureFlagProvider(
        remoteConfig = remoteConfig,
        localCache = context.flagCacheDataStore,
        analytics = analytics
    )
}

Using Feature Flags in Compose UI

Feature flags integrate naturally with Compose. The flag value flows reactively from Remote Config through the ViewModel to the Composable. When a flag changes server-side, the UI updates automatically without an app restart. For structural changes (entire screens behind flags), use the flag in your navigation graph. For cosmetic changes (button colors, copy text), use the flag inline in the composable.
kotlin
@Composable
fun CheckoutScreen(
    viewModel: CheckoutViewModel = hiltViewModel()
) {
    val useNewFlow by viewModel.newCheckoutEnabled
        .collectAsStateWithLifecycle()
    val buttonLabel by viewModel.checkoutButtonLabel
        .collectAsStateWithLifecycle()

    if (useNewFlow) {
        NewCheckoutFlow(
            buttonLabel = buttonLabel,
            onComplete = viewModel::onCheckoutComplete
        )
    } else {
        LegacyCheckoutFlow(
            onComplete = viewModel::onCheckoutComplete
        )
    }
}

class CheckoutViewModel @Inject constructor(
    private val featureFlags: FeatureFlags
) : ViewModel() {

    val newCheckoutEnabled: StateFlow<Boolean> =
        featureFlags.observe(Flag.NewCheckoutFlow)
            .stateIn(viewModelScope, SharingStarted.Eagerly, false)

    val checkoutButtonLabel: StateFlow<String> =
        flowOf(featureFlags.getString(StringFlag.CheckoutButtonLabel))
            .stateIn(viewModelScope, SharingStarted.Eagerly, "Place Order")
}

// In NavHost -- gate entire destinations behind flags
@Composable
fun AppNavHost(
    navController: NavHostController,
    featureFlags: FeatureFlags
) {
    NavHost(navController, startDestination = "home") {
        composable("home") { HomeScreen(navController) }

        if (featureFlags.isEnabled(Flag.AiRecommendations)) {
            composable("recommendations") {
                AiRecommendationsScreen()
            }
        }
    }
}

Debug Overrides and QA Tools

QA teams need the ability to force-enable or force-disable any flag without waiting for Remote Config changes. A debug overlay -- accessible via shake gesture or a hidden developer menu -- lets testers exercise every flag combination on any device. Local overrides take precedence over remote values, and they persist across app restarts via DataStore. A "Reset All" button clears overrides and reverts to remote values.
kotlin
// Debug override layer that wraps the real provider
class DebugFeatureFlagProvider(
    private val realProvider: FeatureFlags,
    private val overrideStore: DataStore<Preferences>
) : FeatureFlags {

    private val overrideKeyPrefix = "override_"

    override fun isEnabled(flag: Flag): Boolean {
        // Check for local override first
        val overrideKey = booleanPreferencesKey(
            "${overrideKeyPrefix}${flag.key}"
        )
        val override = runBlocking {
            overrideStore.data.first()[overrideKey]
        }
        return override ?: realProvider.isEnabled(flag)
    }

    suspend fun setOverride(flag: Flag, value: Boolean) {
        val key = booleanPreferencesKey(
            "${overrideKeyPrefix}${flag.key}"
        )
        overrideStore.edit { it[key] = value }
    }

    suspend fun clearOverride(flag: Flag) {
        val key = booleanPreferencesKey(
            "${overrideKeyPrefix}${flag.key}"
        )
        overrideStore.edit { it.remove(key) }
    }

    suspend fun clearAllOverrides() {
        overrideStore.edit { prefs ->
            prefs.asMap().keys
                .filter { it.name.startsWith(overrideKeyPrefix) }
                .forEach { prefs.remove(it) }
        }
    }

    override fun getString(flag: StringFlag) = realProvider.getString(flag)
    override fun getInt(flag: IntFlag) = realProvider.getInt(flag)
    override fun observe(flag: Flag) = realProvider.observe(flag)
}

Flag Hygiene: Expiration and Cleanup

The number one complaint about feature flags is stale flags that linger in the codebase for months or years after a feature is fully rolled out. A disciplined cleanup process is essential: 1. **Every flag has an owner and an expiration date**: Defined in the flag sealed class itself. No flag ships without both. 2. **Automated lint rule**: A custom Detekt rule scans for flags past their expiration date and fails the build with a clear message: "Flag X expired on Y. Remove the flag and hardcode the winning variant." 3. **Dashboard tracking**: Log every flag evaluation to analytics. Flags that evaluate to the same value for 100% of users for 14+ consecutive days are candidates for cleanup. 4. **Cleanup PRs are first-class work**: Removing a flag is a full code change -- delete the flag definition, remove the conditional branch, keep only the winning code path, update tests. Budget 1-2 hours per flag cleanup per sprint. Teams that enforce flag hygiene maintain an average of 15-25 active flags. Teams without hygiene often accumulate 200+ flags, creating a combinatorial testing nightmare where no one knows what the "real" code path is.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Feature flags decouple deployment from release -- turn features on/off in 30 seconds instead of waiting days for a Play Store update.
  2. 2Wrap Remote Config behind a typed FeatureFlags interface with sealed class flag definitions for compile-time safety.
  3. 3Use percentage-based rollouts: 1% -> 5% -> 25% -> 50% -> 100%, monitoring crash rates at each stage.
  4. 4Every flag needs an owner, an expiration date, and a cleanup plan -- stale flags create combinatorial testing nightmares.
  5. 5Debug overrides via DataStore let QA test every flag combination without Remote Config changes.
  6. 6Teams with flag hygiene maintain 15-25 active flags; teams without accumulate 200+ and lose track of the real code path.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

Should I use feature flags for everything?

No. Use flags for risky features, A/B tests, and gradual rollouts. Don't use flags for stable features - they add complexity. Clean up old flags after full rollout.

How do I test feature flags?

Test with all flag combinations. Use local overrides in development. Test flag fetch and cache behavior. Include flag states in crash reports.

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