Feature Flags in Mobile Apps: Safe Rollouts at Scale

How to implement a robust feature flag system for mobile banking apps, covering architecture, gradual rollouts, kill switches, and the operational patterns that prevent incidents.

technical8 min readBy Klivvr Engineering
Share:

Mobile apps have a deployment problem that web applications do not. When you ship a bug to a web app, you can deploy a fix in minutes. When you ship a bug to a mobile app, the fix must go through app store review, then wait for users to update — a process that takes days to weeks and never reaches 100% of your user base. In a banking app, where a bug might mean incorrect balances, failed transfers, or security vulnerabilities, this deployment asymmetry demands a mitigation strategy.

Feature flags are that strategy. By decoupling deployment from release, feature flags let you ship code to every user's device while controlling who sees the new behavior through server-side configuration. At Klivvr, our feature flag system is a critical piece of operational infrastructure — not a nice-to-have for A/B testing, but a safety net that has prevented multiple potential incidents.

Feature Flag Architecture

Our feature flag system has three components:

Flag definitions live on the server. Each flag has a name, a type (boolean, string, integer, or JSON), a default value, targeting rules, and a kill switch.

Flag evaluation happens on the client. The app fetches the current flag configuration at launch and periodically thereafter, evaluates targeting rules locally, and caches results for offline use.

Flag management is done through an internal dashboard that allows product managers and engineers to create flags, define rollout percentages, target specific user segments, and instantly disable features.

The flag configuration is fetched from a lightweight API endpoint:

// FeatureFlagService.kt
class FeatureFlagService(
    private val apiClient: KlivvrApiClient,
    private val cache: FeatureFlagCache,
    private val userContext: UserContextProvider
) {
    private var flags: Map<String, FeatureFlag> = emptyMap()
 
    suspend fun initialize() {
        // Load cached flags immediately for fast startup
        flags = cache.loadCachedFlags()
 
        // Fetch fresh flags from the server
        try {
            val response = apiClient.getFeatureFlags(
                userId = userContext.getUserId(),
                appVersion = BuildConfig.VERSION_NAME,
                platform = "android",
                locale = userContext.getLocale()
            )
            flags = response.flags.associateBy { it.name }
            cache.saveFlagCache(flags)
        } catch (e: Exception) {
            // Network failure is not fatal — we use cached flags
            Logger.warn("Failed to fetch feature flags, using cache", e)
        }
    }
 
    fun isEnabled(flagName: String, defaultValue: Boolean = false): Boolean {
        val flag = flags[flagName] ?: return defaultValue
        return evaluateFlag(flag) as? Boolean ?: defaultValue
    }
 
    fun getString(flagName: String, defaultValue: String = ""): String {
        val flag = flags[flagName] ?: return defaultValue
        return evaluateFlag(flag) as? String ?: defaultValue
    }
 
    private fun evaluateFlag(flag: FeatureFlag): Any {
        // Kill switch takes absolute precedence
        if (flag.killed) return flag.defaultValue
 
        // Evaluate targeting rules in order
        for (rule in flag.targetingRules) {
            if (rule.matches(userContext)) {
                return rule.value
            }
        }
 
        // Percentage rollout
        if (flag.rolloutPercentage != null) {
            val hash = consistentHash(flag.name, userContext.getUserId())
            if (hash < flag.rolloutPercentage) {
                return flag.enabledValue
            }
        }
 
        return flag.defaultValue
    }
 
    private fun consistentHash(flagName: String, userId: String): Int {
        // Murmur3 hash ensures consistent assignment:
        // the same user always gets the same flag value for a given flag
        val input = "$flagName:$userId"
        return abs(MurmurHash3.hash32(input.toByteArray())) % 100
    }
}

The consistent hashing is crucial. When you roll out a feature to 10% of users, then increase to 20%, the original 10% must remain in the enabled group. A naive random check would re-randomize on every evaluation, causing users to see the feature flicker on and off.

Using Feature Flags in Practice

In the codebase, feature flags are consumed through a clean API that reads naturally:

// TransferScreen.kt
@Composable
fun TransferScreen(viewModel: TransferViewModel) {
    val flags = LocalFeatureFlags.current
 
    Column {
        TransferAmountInput(/* ... */)
 
        if (flags.isEnabled("instant_transfer_v2")) {
            InstantTransferOption(
                fee = viewModel.instantTransferFee,
                onSelected = { viewModel.selectInstantTransfer() }
            )
        }
 
        if (flags.isEnabled("scheduled_transfers")) {
            ScheduleTransferOption(
                onDateSelected = { viewModel.setScheduledDate(it) }
            )
        }
 
        TransferButton(onClick = { viewModel.initiateTransfer() })
    }
}

On iOS with SwiftUI:

// TransferView.swift
struct TransferView: View {
    @ObservedObject var viewModel: TransferViewModel
    @EnvironmentObject var featureFlags: FeatureFlagService
 
    var body: some View {
        VStack {
            TransferAmountInput(amount: $viewModel.amount)
 
            if featureFlags.isEnabled("instant_transfer_v2") {
                InstantTransferOption(
                    fee: viewModel.instantTransferFee,
                    onSelected: { viewModel.selectInstantTransfer() }
                )
            }
 
            if featureFlags.isEnabled("scheduled_transfers") {
                ScheduleTransferOption(
                    onDateSelected: { viewModel.setScheduledDate($0) }
                )
            }
 
            TransferButton(action: { viewModel.initiateTransfer() })
        }
    }
}

Gradual Rollout Patterns

We use several rollout patterns depending on the risk profile of the feature:

Percentage rollout is the most common. Start at 1%, monitor error rates and user feedback for 24 hours, then increase to 5%, 10%, 25%, 50%, and finally 100%. Each step includes a monitoring checkpoint.

Segment targeting rolls out to specific user groups first. For a new investment product, we might target internal employees first (the "dogfood" segment), then beta users who opted in, then users in a specific country, and finally the general population.

Version gating restricts a feature to specific app versions. This is essential when a feature requires both a client-side change and a backend API change. The flag ensures that only app versions with the client-side code can access the new behavior:

// Targeting rule that combines version and percentage
data class TargetingRule(
    val conditions: List<Condition>,
    val value: Any
) {
    fun matches(context: UserContext): Boolean {
        return conditions.all { condition ->
            when (condition) {
                is Condition.MinAppVersion ->
                    context.appVersion >= condition.version
                is Condition.UserSegment ->
                    context.segments.contains(condition.segment)
                is Condition.Country ->
                    context.country == condition.countryCode
                is Condition.Percentage ->
                    consistentHash(context) < condition.value
            }
        }
    }
}

The Kill Switch

The most important feature flag capability is the ability to turn something off instantly. Every feature flag at Klivvr has a kill switch — a single boolean that, when activated, forces the flag to its default (off) value for all users, regardless of targeting rules or rollout percentages.

The kill switch is designed for speed:

  1. Activation is a single API call. No multi-step confirmation dialogs. In an incident, seconds matter.
  2. Propagation is near-instant. The app polls for flag updates every 5 minutes, but the kill switch also pushes a silent notification that triggers an immediate flag refresh.
  3. The kill switch is accessible from mobile. Our on-call engineers can activate it from the internal mobile dashboard app, not just from a desktop browser.
// Silent push handler that triggers immediate flag refresh
class SilentPushHandler {
    fun handleSilentPush(data: Map<String, String>) {
        if (data["type"] == "flag_update") {
            CoroutineScope(Dispatchers.IO).launch {
                FeatureFlagService.getInstance().initialize()
            }
        }
    }
}

We have activated kill switches three times in the past year. In one case, a new transaction categorization feature was producing incorrect merchant labels for a specific payment processor. The kill switch disabled the feature within 90 seconds of the first report, long before a hotfix could have been built, reviewed, and submitted to the app stores.

Flag Lifecycle Management

Feature flags are technical debt by nature. Every flag is an if-statement that increases code complexity, and stale flags make the codebase harder to reason about. We enforce a strict lifecycle:

Creation. Every flag requires an owner (a team), a description, an expected removal date, and a rollout plan. The expected removal date is typically 4-8 weeks after the feature reaches 100% rollout.

Monitoring. Each flag has associated metrics — error rates, performance impact, user engagement — that are tracked on a dedicated dashboard.

Cleanup. When a flag reaches 100% rollout and the removal date arrives, a Jira ticket is automatically created for the owning team to remove the flag from the codebase. The cleanup involves removing the conditional logic, deleting the flag definition, and updating tests.

Staleness alerts. If a flag remains in the codebase past its expected removal date, our CI pipeline generates a warning. After two weeks of warnings, it escalates to the team lead. We currently maintain approximately 30 active flags; without this process, we estimated we would have over 200.

// Build-time lint check for stale flags
// build.gradle.kts
tasks.register("checkStaleFlags") {
    doLast {
        val flagRegistry = file("feature_flags_registry.json")
        val flags = Json.decodeFromString<List<FlagDefinition>>(flagRegistry.readText())
        val staleFlags = flags.filter {
            it.expectedRemovalDate.isBefore(LocalDate.now()) &&
            it.status != FlagStatus.REMOVED
        }
        if (staleFlags.isNotEmpty()) {
            logger.warn("Stale feature flags detected:")
            staleFlags.forEach { logger.warn("  - ${it.name} (due: ${it.expectedRemovalDate})") }
        }
    }
}

Testing with Feature Flags

Feature flags complicate testing because every flag doubles the number of possible code paths. Our testing strategy addresses this:

Unit tests test both paths. For any code guarded by a feature flag, we write tests with the flag on and off:

@Test
fun `transfer screen shows instant option when flag is enabled`() {
    featureFlags.override("instant_transfer_v2", true)
    val screen = composeTestRule.setContent { TransferScreen(viewModel) }
    screen.onNodeWithText("Instant Transfer").assertIsDisplayed()
}
 
@Test
fun `transfer screen hides instant option when flag is disabled`() {
    featureFlags.override("instant_transfer_v2", false)
    val screen = composeTestRule.setContent { TransferScreen(viewModel) }
    screen.onNodeWithText("Instant Transfer").assertDoesNotExist()
}

Integration tests run with flags at their production values. Our CI pipeline fetches the current production flag configuration and uses it as the default for integration tests. This ensures that tests reflect what users actually experience.

End-to-end tests cover the kill switch. We have a dedicated test that enables a feature, performs an action, activates the kill switch, and verifies that the feature is no longer accessible.

Conclusion

Feature flags transform mobile app deployment from a high-stakes, irreversible event into a controlled, reversible process. For a banking app where bugs have financial consequences and app store updates take days, this transformation is not optional — it is essential infrastructure. The investment is not just in the flag evaluation library; it is in the operational practices around gradual rollouts, kill switches, lifecycle management, and testing discipline. Done right, feature flags give your team the confidence to ship faster, knowing that any problem can be contained and reversed within seconds rather than days.

Related Articles

business

Reducing Friction in Fintech User Onboarding

Strategies and technical approaches for streamlining fintech user onboarding, from identity verification and KYC to progressive profiling, while balancing regulatory compliance with conversion optimization.

11 min read
business

Layered Security Architecture for Mobile Banking

An in-depth look at the multi-layered security architecture that protects mobile banking apps, from device integrity checks and encrypted storage to runtime protection and network security.

9 min read
business

App Store Optimization for Fintech Products

A comprehensive guide to App Store Optimization (ASO) for fintech mobile apps, covering keyword strategy, creative optimization, ratings management, and the unique challenges of marketing a financial product in competitive app stores.

9 min read