A feature flag read on four platforms needs one source of truth and four caches
Flags that differ per platform at the same moment produce bug reports nobody can reproduce.
TL;DR
A feature flag read with flags.isEnabled("new_checkout") looks like reading a constant. It is a
network-backed value with a per-platform cache, and it can change between two reads in the same
session.
That produces states your code never considered: a checkout that starts on the new flow and finishes on the old one, a list rendered with one layout and paginated with another. The bug reports are unreproducible because the flag that produced them has since changed.
Read once per session into an immutable snapshot, pass the snapshot down, and make the refresh an explicit decision with a declared point of effect. The per-platform SDK is a cache behind one shared interface.
The case this post is about
A checkout flow behind a flag, read where it is needed — which is the obvious and wrong place:
// commonMain
class CheckoutViewModel(private val flags: FeatureFlags) {
fun start() {
if (flags.isEnabled("new_checkout")) startNewFlow() else startLegacyFlow()
}
fun submit(cart: Cart) {
// read again, several screens and possibly minutes later
val endpoint = if (flags.isEnabled("new_checkout")) "/v2/orders" else "/v1/orders"
api.post(endpoint, cart)
}
fun confirmation(): Screen =
if (flags.isEnabled("new_checkout")) NewConfirmation else LegacyConfirmation
}
Three reads of one flag at three moments. Each read is correct; the combination is not guaranteed to be consistent.
The symptom
Four findings.
Orders posted to the wrong endpoint shape. A /v1/orders payload built by the new flow:
POST /v1/orders 400
body: { "lineItems": [...] } ← v2 shape
expected: { "items": [...] }
The flag had flipped between start() and submit() — a staged rollout advancing mid-session.
A different flag value per platform at the same moment. Same user, same account, two devices:
phone (Android, cached 4h ago) new_checkout = false
tablet (iOS, fetched just now) new_checkout = true
Both SDKs were behaving correctly with their own cache TTL. The user saw two different apps.
Unreproducible bug reports. By the time anyone investigated, the flag had reached 100% and the mixed state was impossible to recreate. The reports said "the checkout broke once".
And wasm had no cache at all, so it re-fetched on every page load and could disagree with itself across a reload — a fourth behaviour from the same shared call.
Why the obvious fix failed
"Read the flag once in start() and store it in a field." Right direction, and it fixed only
this one ViewModel. The flag was read in six other places, and the next feature's author will read
it wherever they need it.
"Set a long cache TTL everywhere." Reduces the window and does not close it, and now a flag flip takes hours to roll out — trading a rare correctness bug for a permanent operational limitation.
"Fetch flags synchronously at startup and block." My first version, and it made cold start dependent on a network call. On a bad connection the app hung at a splash screen, which is a worse bug than the one being fixed.
"Use the platform SDK's real-time update stream." The SDKs offer this and it makes the problem worse: more frequent changes mean more mid-session flips. Real-time updates are a feature for kill switches, not for flow selection.
The actual mechanism
Three separate things that "a feature flag" bundles.
A remote value, owned by a service. It changes on someone else's schedule — a rollout percentage advancing, an experiment starting, a kill switch firing.
A per-platform cache. Each SDK has its own storage and its own TTL. Android persists to disk,
iOS to its own store, a browser to localStorage or nothing. These are independent and will
diverge.
A read at a moment in time. Which is the part the call site hides. isEnabled(...) looks
pure and is a point sample of a value that moves.
So a flow that reads the same flag three times is making three independent samples and assuming they agree. Over a short flow they usually do, which is why this ships.

A flag read is a sample, not a constant. Consistency within a unit of work is a property you have to create, by sampling once and passing the sample — not something the flag system provides.
The fix
One snapshot per session, immutable, injected. Refreshes land at declared boundaries.
// commonMain — the snapshot IS the type the app depends on
data class FlagSnapshot(
private val values: Map<String, Boolean>,
val fetchedAt: Instant,
val source: Source,
) {
enum class Source { Network, Cache, Defaults }
fun isEnabled(key: String): Boolean = values[key] ?: DEFAULTS[key] ?: false
}
// commonMain — the provider owns refresh; consumers never see it
class FlagProvider(private val client: FlagClient, private val clock: Clock) {
private val _current = MutableStateFlow(FlagSnapshot(emptyMap(), clock.now(), Defaults))
val current: StateFlow<FlagSnapshot> = _current.asStateFlow()
/** Non-blocking. Startup proceeds on cached or default values immediately. */
suspend fun refresh() {
val fetched = runCatching { client.fetch() }.getOrNull() ?: return
_current.value = FlagSnapshot(fetched, clock.now(), Network)
}
}
The consumer takes a snapshot, once:
// commonMain — one sample for the whole flow
class CheckoutViewModel(private val flags: FlagSnapshot) {
private val useNewCheckout = flags.isEnabled("new_checkout") // read ONCE, at construction
fun start() = if (useNewCheckout) startNewFlow() else startLegacyFlow()
fun submit(cart: Cart) = api.post(if (useNewCheckout) "/v2/orders" else "/v1/orders", cart)
fun confirmation() = if (useNewCheckout) NewConfirmation else LegacyConfirmation
}
Four decisions:
The ViewModel takes FlagSnapshot, not FeatureFlags. This is the structural fix. There is no
way to re-read mid-flow, because the dependency is a value rather than a service. The six other
call sites get the same treatment and the next author cannot do it wrong.
Startup never blocks. The provider begins with cached-or-default values and refreshes in the background, so a slow network delays a flag update, never the launch.
Refresh takes effect at a declared boundary — not mid-flow:
// commonMain — the app shell decides when a new snapshot is adopted
@Composable
fun AppRoot(provider: FlagProvider) {
// A new snapshot is adopted at the root, so flows started under the old one finish under it.
val snapshot by provider.current.collectAsStateWithLifecycle()
key(snapshot.fetchedAt) { NavGraph(snapshot) }
}
key(...) is deliberate and is the trade: adopting a new snapshot resets the navigation graph, so
it must only happen when the user is not mid-flow. In practice we refresh on cold start and on
return-from-background, never while a flow is open.
And one kill-switch exception, modelled separately — because "stop immediately" is genuinely a different requirement from "which flow":
// commonMain — read live, on purpose, and only for disabling
class KillSwitches(private val provider: FlagProvider) {
fun isPaymentDisabled(): Boolean = provider.current.value.isEnabled("payments_kill")
}
Naming it KillSwitches rather than putting it in the snapshot is what keeps the live-read
exception from spreading.
The platform SDK becomes one implementation of FlagClient, which is where the per-platform
cache divergence gets contained:
// commonMain
interface FlagClient { suspend fun fetch(): Map<String, Boolean> }
// androidMain / iosMain / wasmJsMain / desktopMain → each wraps its SDK or a plain HTTP call
How we proved it
That a mid-flow flip no longer splits a flow — the regression test for the shipped bug:
// commonTest
@Test
fun flagFlipMidFlowDoesNotChangeTheEndpoint() = runTest {
val provider = FlagProvider(FakeClient(mapOf("new_checkout" to true)), testClock)
provider.refresh()
val vm = CheckoutViewModel(provider.current.value) // snapshot taken here
vm.start()
// the rollout advances mid-session
provider.refresh(FakeClient(mapOf("new_checkout" to false)))
vm.submit(cart)
assertEquals("/v2/orders", api.lastEndpoint) // still the flow it started on
}
before (service injected): FAILED — "/v1/orders"
after (snapshot injected): PASSED
That the structural guard holds — the test that matters more than the one above, because it prevents the class rather than the instance:
@Test
fun viewModelsDependOnSnapshotsNotProviders() {
// a compile-time property, asserted as a readable fact
val ctor = CheckoutViewModel::class.constructors.first()
assertTrue(ctor.parameters.none { it.type.toString().contains("FlagProvider") })
}
Crude reflection, and it encodes the actual invariant: no consumer may hold something it can re-read. (On a JVM-only test source set — the point is the invariant, and a lint rule would be the better home for it.)
That startup does not block on the network:
cold start, network stalled (dead listener)
before: splash held 30s, then timeout
after: first frame 420ms, source=Cache, refresh completes later or not at all
And the cross-device divergence, measured rather than assumed:
same account, flag at 50% rollout
before: phone=false / tablet=true at the same instant (independent SDK caches)
after: phone=true / tablet=true — both snapshots fetched at session start,
and a flow started under one value completes under it
Two honest limits. Divergence between devices is reduced, not eliminated — two sessions starting
at different times legitimately sample different values, and the fix is that each session is
internally consistent, not that all devices agree. And key(snapshot.fetchedAt) resetting the nav
graph is a real constraint: adopt a new snapshot at the wrong moment and you interrupt the user.
We gate it on cold start and foreground, which is a policy choice rather than a guarantee.
What this generalizes to
Inject a value, not a service, when consistency within a unit of work matters. A snapshot cannot be re-read. This is the same move as passing a timestamp instead of a clock, or a resolved config instead of a config loader — the type system enforces the sampling discipline.
Remote configuration changes under you, and the call site hides it. Anything that looks like reading a constant but crosses a network — a flag, a remote config value, a pricing table, an entitlement — needs an explicit answer to "what happens if this changes mid-operation".
"Immediately" and "consistently" are competing requirements, so model them separately. A kill switch wants the newest value now. A flow selector wants one value throughout. Putting both in one API means one of them is wrong, and naming them differently is what keeps the exception contained.
Reduce divergence rather than promising agreement. Independent caches on independent devices cannot be made to agree at an instant. Per-session internal consistency is achievable and is usually what the bug reports were actually about.
Related
- A Flow is cold, and that is why your ViewModel fires three network calls — sampling versus subscribing
- kotlinx-datetime has no Calendar, and the timezone database ships per platform — injecting a value instead of reading an environment
- StateFlow drops what you needed and keeps what you didn't — choosing a type by its semantics
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".