
A driver doesn’t pit to change the engine map. There’s a dial on the wheel, and switching from a fuel-saving map to an attack map takes a thumb press mid-lap. The car’s behaviour changes without anyone touching the garage. That’s the whole appeal: adjust strategy without stopping the race.
Feature flags are the same dial for an app. Turn a broken feature off without an App Store review cycle. Roll a change out to ten percent of users and watch what happens before committing the rest of the field. The trap in KMP is building that dial twice, once per platform, with two different opinions about what “on” means. I want one flag evaluation that both platforms trust, because a flag that reads differently on Android and iOS isn’t a flag anymore, it’s a bug with a config file attached.
One interface, evaluated once
The flag check itself, the part that decides true or false for a given user, belongs entirely in commonMain. Neither platform should be running its own logic for percentage rollouts or default fallbacks. If that logic lives twice, it drifts twice.
// commonMain
interface FeatureFlags {
fun isEnabled(flag: Flag): Boolean
fun variant(flag: Flag): String?
}
enum class Flag(val key: String, val default: Boolean) {
NEW_CHECKOUT("new_checkout", default = false),
OFFLINE_SYNC("offline_sync", default = true),
}
Every call site in shared code asks the same question the same way:
class CheckoutViewModel(
private val flags: FeatureFlags,
) {
fun checkoutFlow(): CheckoutFlow =
if (flags.isEnabled(Flag.NEW_CHECKOUT)) NewCheckoutFlow() else LegacyCheckoutFlow()
}
Nothing here is platform-specific yet, and that’s deliberate. The decision of what is enabled is a data problem, not a platform problem. Where things get platform-specific is how the flag value gets there, and that’s the next layer down.
Remote config is a cache with a fetch attached, not a database
The naive version of remote config fetches on every app launch and blocks the UI until the network call resolves. That’s a mistake I made on an early project: a slow network turned every cold start into a spinner, because the flag values were treated as required data instead of a cache with a fallback.
RemoteConfigClient below is deliberately just an interface. Behind it you can put Firebase Remote Config, LaunchDarkly, ConfigCat, or your own backend endpoint returning a JSON blob of key-value pairs. None of them change the shape of this pattern, they’re all a fetch that returns a map and a way to tell it to refresh. Firebase Remote Config in particular already does a version of the caching below internally, so if that’s your provider you’re layering FlagCache on top of a client that’s already somewhat cache-aware. Wrap whichever one you use behind this interface and the rest of commonMain never needs to know which SDK is doing the fetching.
The pattern that holds up is cache-first with a background refresh:
class RemoteFeatureFlags(
private val remote: RemoteConfigClient,
private val cache: FlagCache,
) : FeatureFlags {
override fun isEnabled(flag: Flag): Boolean =
cache.get(flag.key)?.toBooleanStrictOrNull() ?: flag.default
suspend fun refresh() {
val fetched = remote.fetch()
cache.putAll(fetched)
}
}
isEnabled always returns instantly, from whatever’s already in cache. refresh() runs on app start and periodically after, updating the cache for the next read, not the current one. The user’s first session ever gets sensible hardcoded defaults. Every session after that gets whatever was cached last time, updated in the background. Nobody waits on a network call to see a checkout button.
The FlagCache itself is a small expect/actual surface, backed by whatever local persistence you’re already using for settings (DataStore does this well; the flag cache is just one more thing stored the same way, not a new mechanism). Worth calling out: DataStore itself now ships as a Kotlin Multiplatform library, so you don’t strictly need the expect/actual split just to get it working on both platforms. You only reach for expect/actual here if you need platform-specific behaviour around the cache, a different flush strategy, encryption on one side, that kind of thing. If you just want a shared key-value store for flag values, pull in multiplatform DataStore directly in commonMain and skip the extra indirection.
Local overrides for debug builds
QA needs to force a flag on before it’s rolled out anywhere, and you need that override to never leak into a release build by accident. This is one of the few places where I reach for a genuinely platform-specific expect/actual, because “is this a debug build” is answered differently on each side.
// commonMain
expect fun isDebugBuild(): Boolean
// androidMain
actual fun isDebugBuild(): Boolean = BuildConfig.DEBUG
// iosMain
actual fun isDebugBuild(): Boolean = Platform.isDebugBinary
Wrap the override on top of the remote-backed implementation rather than replacing it, so the production path is never bypassed silently:
class OverridableFeatureFlags(
private val delegate: FeatureFlags,
private val overrides: MutableMap<Flag, Boolean> = mutableMapOf(),
) : FeatureFlags {
override fun isEnabled(flag: Flag): Boolean =
if (isDebugBuild()) overrides[flag] ?: delegate.isEnabled(flag)
else delegate.isEnabled(flag)
fun setOverride(flag: Flag, value: Boolean) {
check(isDebugBuild()) { "Overrides are debug-only" }
overrides[flag] = value
}
}
The check in setOverride is not decoration. Without it, a debug-only QA panel that somehow ships its call site into production silently starts flipping flags for real users, and that’s a much worse bug than the one the flag was supposed to gate.
The wrong way I keep seeing
The naive approach is a const val NEW_CHECKOUT_ENABLED = true sitting in commonMain, flipped by hand and shipped through a full release whenever someone wants to change it. That’s not a feature flag, it’s a compile-time constant wearing a flag’s name. It buys none of the actual benefit, no gradual rollout, no instant kill switch, no A/B split, and it costs a full app store review cycle for every change of mind.
The second version of this mistake is subtler: a real remote config system, but with commonMain code that reads the flag value once at app start and holds it for the whole session. If you push a kill switch for a broken feature mid-session, users who already launched the app don’t see it until they restart. For anything you might need to disable urgently, evaluate the flag at the point of use, not once at startup, and make refresh() actually update live state rather than a value nobody rereads.
Gotchas
Defaults are a design decision, not an afterthought. Every Flag.default should be the value you’d want if remote config never loads, ever, for any user. Treat “what if this never resolves” as a real scenario, because on a bad network day it’s the scenario some fraction of your users are actually living in.
Don’t gate anything load-bearing behind a single remote flag with no local fallback. If the config service has an outage and your login flow is entirely flag-controlled with no sane default, you’ve built a single point of failure that has nothing to do with your own backend.
Testing code that depends on FeatureFlags needs a deterministic fake, not the real remote-backed implementation. A simple FakeFeatureFlags(overrides: Map<Flag, Boolean>) implementing the same interface keeps flag-dependent tests predictable across all three source sets, same as any other dependency you’d fake in shared tests.
Variant flags (A/B/C, not just on/off) need a stable per-user bucket, not a random draw on every evaluation. If variant() returns a different answer each time it’s called for the same user, you don’t have an experiment, you have noise.
The line I hold
A feature flag that behaves differently on Android and iOS has already failed at its one job. The whole value of putting the evaluation logic in commonMain is that “is this on” means the same thing everywhere, checked once, trusted everywhere. Cache the value, refresh it in the background, never block the UI waiting for it, and keep the debug override honest about being debug-only.
Change the map without a pit stop. Just make sure both sides of the garage agree on which map is running. 🏁
One more change from my side: KMP Bits is moving to an every-two-weeks schedule from here on. Same depth, just more time between laps.
The KMP Bits app is available on App Store and Google Play — built entirely with KMP.
Comments
Loading comments...