Engineering

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.

MobileByteSensei 8 min read
kmparchitectureoperations

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.

One remote flag value owned by a rollout service, fanning into four independent platform caches with their own TTLs, each sampled at the moment a call site asks — so three reads across one checkout flow are three independent samples, and two devices on one account can hold different values at the same instant

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.

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".

Follow by RSS