This is the implementation material behind the feature flags interview case. It covers:
- the flag list, the contract and rollout buckets
- the store, the saved copy and the vendor adapter
- the kill switch, the crash guard, privacy, testing and cleanup
- platform limits and the iOS version
None of it belongs in a 45-minute round (the length I assume). I draw it only if asked. The Kotlin was compiled with Firebase BoM 34.19.0, which gave firebase-config 23.1.0. The Firebase pages I read on 14 October 2026 show BoM 35.0.0 and firebase-config 24.0.0. Check the current release notes before you copy a call name. The Swift store was type-checked. The Firebase Swift adapter was not compiled.
1. The flag list
Every flag is declared in code. It has a key, a default, an owner and an expiry date. A kill switch has no date. The default is the behavior that already shipped and worked.
expired() is for CI. A flag past its date fails the build until someone removes it.
package flags
import java.time.LocalDate
/**
* A flag, declared in code. `default` is the behavior that already shipped and worked.
* A kill switch has no expiry date. Every other flag must have one.
*/
class Flag(
val key: String,
val default: Boolean,
val owner: String,
val expires: LocalDate? = null,
val killSwitch: Boolean = false,
) {
init { require(killSwitch || expires != null) { "$key needs an expiry date" } }
}
object Flags {
val NewCheckout = Flag("new_checkout", default = false, owner = "payments", expires = LocalDate.of(2027, 3, 1))
val ChatMedia = Flag("chat_media_enabled", default = true, owner = "chat", killSwitch = true)
val all = listOf(NewCheckout, ChatMedia)
/** Run this in CI. A flag past its date fails the build until someone removes it. */
fun expired(today: LocalDate): List<Flag> = all.filter { it.expires?.isBefore(today) == true }
}
2. The contract
This is my design, not a vendor API. The phone sends the least it can. The server evaluates the rules and returns only answers.
POST /v1/flags/evaluate
body: { platform, appVersion, accountId | null, installId }
→ 200 { version, flags: { "new_checkout": true, "chat_media_enabled": true } }
→ 304 when the client sends If-None-Match with the last version
- Unknown keys are ignored by the app. A missing key means "use the default in the code".
- Rules stay on the server. Unleash advises against evaluation on the client, because it exposes flag configuration and context. It suggests sending the context to an evaluation server and caching the results (Unleash best practices).
- LaunchDarkly mobile SDKs use the same shape. The SDK gets values for one context (LaunchDarkly SDK types).
- Do not put secrets in flag values. Firebase says users can read any default or fetched value (Firebase get started).
3. Rollout buckets
The bucket is a number from 0 to 9,999. It comes from a hash of the flag key and a stable ID. The same account and flag always give the same bucket. A bigger percentage only adds buckets, so the first group stays in.
LaunchDarkly describes the same idea with 100,000 buckets from the context key and kind. It says each flag assigns people on its own (LaunchDarkly percentage rollouts). Unleash hashes a context field with the flag name, and it says stickiness is not guaranteed when no user or session field exists (Unleash stickiness).
This code runs on the server. I show it here so you can test the property. The Android and iOS versions give the same number for the same input (1500 for acct-42 and new_checkout).
package flags
import java.security.MessageDigest
/**
* Rollout bucket, 0 to 9,999. Runs on the server. It is shown here so the property can be tested:
* the same account and flag always give the same bucket, and a larger percentage only adds buckets.
*/
object Bucket {
fun of(stableId: String, flagKey: String): Int {
val d = MessageDigest.getInstance("SHA-256").digest("$flagKey:$stableId".toByteArray())
val n = (d[0].toLong() and 0xff shl 24) or (d[1].toLong() and 0xff shl 16) or
(d[2].toLong() and 0xff shl 8) or (d[3].toLong() and 0xff)
return (n % 10_000).toInt()
}
/** `basisPoints` 1,000 means 10 percent. */
fun isIn(stableId: String, flagKey: String, basisPoints: Int): Boolean = of(stableId, flagKey) < basisPoints
}
Which ID to hash:
- Signed in: the account ID. The group then follows the person to a new phone.
- Signed out: the install ID. The group changes after a reinstall.
- Firebase's own random percent rule maps each app instance to a number, and keeps it (Firebase client templates). That follows the install, not the account.
To get account-stable groups on Firebase, send the bucket as a custom signal. Firebase conditions can compare custom signals as numbers (Firebase condition reference). A console rule can then say rollout_bucket < 1000 for 10 percent. The adapter in section 6 sets the signal. Flags then share one bucket, so the same people are first in every rollout. Send one signal per flag if that matters.
4. The store
The store is the center of the design. It reads local disk at start and never waits for the network. A screen takes a view once. The answers in that view stay the same. A kill switch is the one value read live.
Three details:
start()counts starts that never finish. After two, it deletes the saved copy and uses the defaults.refreshIfStale()treats a negative age as stale. If the device clock moved back, it fetches.switchUser()only loads a copy saved for that user. One user's values never reach another user.
package flags
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class FlagSnapshot(
val userKey: String?, // null means signed out
val fetchedAtMillis: Long, // 0 means never fetched
val values: Map<String, Boolean>,
)
interface FlagSource {
/** Returns the values for this user, or null when the fetch failed. */
suspend fun fetch(userKey: String?): Map<String, Boolean>?
}
interface FlagDisk {
suspend fun read(): FlagSnapshot?
suspend fun write(snapshot: FlagSnapshot)
suspend fun clear()
suspend fun unfinishedStarts(): Int
suspend fun setUnfinishedStarts(n: Int)
}
class FlagStore(
private val source: FlagSource,
private val disk: FlagDisk,
private val now: () -> Long,
private val flags: List<Flag> = Flags.all,
private val maxAgeMillis: Long = 15 * 60_000L,
) {
private val killKeys = flags.filter { it.killSwitch }.map { it.key }.toSet()
private val lock = Mutex()
private var userKey: String? = null
private var session = FlagSnapshot(null, 0, emptyMap()) // fixed for the whole session
private var lastFetchAt = 0L
private val live = MutableStateFlow<Map<String, Boolean>>(emptyMap()) // kill switches only
/** Call once, before the first frame. It reads local disk only. It never waits for the network. */
suspend fun start(userKey: String?) = lock.withLock {
this.userKey = userKey
val unfinished = disk.unfinishedStarts()
disk.setUnfinishedStarts(unfinished + 1)
val saved = disk.read()
session = when {
unfinished >= 2 -> { disk.clear(); empty(userKey) } // two starts never finished: safe mode
saved != null && saved.userKey == userKey -> saved
else -> empty(userKey)
}
lastFetchAt = session.fetchedAtMillis
live.value = session.values.filterKeys { it in killKeys }
}
/** Call a few seconds after the first frame. It resets the crash counter. */
suspend fun startFinished() = disk.setUnfinishedStarts(0)
/** A screen takes one view when it opens. The answers stay the same until the screen closes. */
fun view(): FlagView = FlagView(session.values, this)
internal fun liveValue(flag: Flag): Boolean = live.value[flag.key] ?: flag.default
suspend fun refreshIfStale() {
val age = now() - lastFetchAt
if (age in 0 until maxAgeMillis) return // a negative age means the clock moved back: fetch
refresh()
}
suspend fun refresh() = lock.withLock {
val fresh = source.fetch(userKey) ?: return@withLock // failed: keep what we have
lastFetchAt = now()
disk.write(FlagSnapshot(userKey, lastFetchAt, fresh)) // the next session reads this
live.value = fresh.filterKeys { it in killKeys } // kill switches change now
}
/** Sign in or out. The old user's values must not reach the new user. */
suspend fun switchUser(newKey: String?) {
lock.withLock {
userKey = newKey
val saved = disk.read()
session = if (saved != null && saved.userKey == newKey) saved else empty(newKey)
lastFetchAt = session.fetchedAtMillis
live.value = session.values.filterKeys { it in killKeys }
}
refresh()
}
private fun empty(userKey: String?) = FlagSnapshot(userKey, 0, emptyMap())
}
class FlagView internal constructor(private val values: Map<String, Boolean>, private val store: FlagStore) {
fun isOn(flag: Flag): Boolean =
if (flag.killSwitch) store.liveValue(flag) else values[flag.key] ?: flag.default
}
5. The saved copy (DataStore)
DataStore is a Jetpack library for small saved settings. Android says it stores data asynchronously, consistently and transactionally (Android DataStore). One edit call is one write, so a crash cannot leave half a snapshot. I rely on that sentence, and I did not test a crash.
package flags
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
private val Context.flagStore by preferencesDataStore(name = "flags")
class DataStoreFlagDisk(private val context: Context, private val flags: List<Flag> = Flags.all) : FlagDisk {
private val userKey = stringPreferencesKey("_user")
private val fetchedAt = longPreferencesKey("_fetched_at")
private val written = booleanPreferencesKey("_written")
private val unfinished = intPreferencesKey("_unfinished_starts")
override suspend fun read(): FlagSnapshot? {
val p = context.flagStore.data.first()
if (p[written] != true) return null
val values = flags.mapNotNull { f -> p[booleanPreferencesKey(f.key)]?.let { f.key to it } }.toMap()
return FlagSnapshot(p[userKey], p[fetchedAt] ?: 0L, values)
}
// One edit is one transaction, so a crash cannot leave half a snapshot.
override suspend fun write(snapshot: FlagSnapshot) {
context.flagStore.edit { p ->
flags.forEach { p.remove(booleanPreferencesKey(it.key)) }
snapshot.values.forEach { (k, v) -> p[booleanPreferencesKey(k)] = v }
val user = snapshot.userKey
if (user == null) p.remove(userKey) else p[userKey] = user
p[fetchedAt] = snapshot.fetchedAtMillis
p[written] = true
}
}
override suspend fun clear() {
context.flagStore.edit { p ->
flags.forEach { p.remove(booleanPreferencesKey(it.key)) }
p.remove(userKey); p.remove(fetchedAt); p.remove(written)
}
}
override suspend fun unfinishedStarts(): Int = context.flagStore.data.first()[unfinished] ?: 0
override suspend fun setUnfinishedStarts(n: Int) { context.flagStore.edit { it[unfinished] = n } }
}
6. The vendor adapter (Firebase)
The adapter has three jobs.
fetch()asks Firebase for new values. It also callsactivate(), which moves Firebase's own copy forward. Screens read the store, not Firebase.- It keeps only values that came from the server. For the rest, the default in the code applies. The Android reference has
getSource()andVALUE_SOURCE_REMOTEfor this (FirebaseRemoteConfigValue). onKillSwitchChanged()listens for live updates. They run only in the foreground (Firebase real-time).
Fetch obeys the minimum fetch interval. The default is 12 hours (Firebase get started). A call inside that window does not reach the network. Real-time updates skip the interval. That is why the kill switch uses them.
package flags
import com.google.firebase.remoteconfig.ConfigUpdate
import com.google.firebase.remoteconfig.ConfigUpdateListener
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigException
import com.google.firebase.remoteconfig.customSignals
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.tasks.await
class FirebaseFlagSource(private val rc: FirebaseRemoteConfig) : FlagSource {
/** Fetch respects the minimum fetch interval (12 hours by default), so a call can be a no-op. */
override suspend fun fetch(userKey: String?): Map<String, Boolean>? = try {
rc.fetch().await()
rc.activate().await() // this moves Firebase's own copy forward. Screens read FlagStore, not Firebase.
// Keep only values that came from the server. For the rest, the Flag default in code applies.
Flags.all
.filter { rc.getValue(it.key).source == FirebaseRemoteConfig.VALUE_SOURCE_REMOTE }
.associate { it.key to rc.getBoolean(it.key) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { // throttled, offline, timeout
null
}
/** Send a rollout bucket that follows the account, not the install. A console rule can say bucket < 1000. */
fun setBucket(accountId: String?) {
rc.setCustomSignals(customSignals {
put("rollout_bucket", if (accountId == null) -1L else Bucket.of(accountId, "all").toLong())
})
}
/** Real-time updates run only while the app is in the foreground. Use them for kill switches. */
fun onKillSwitchChanged(action: () -> Unit) {
val killKeys = Flags.all.filter { it.killSwitch }.map { it.key }.toSet()
rc.addOnConfigUpdateListener(object : ConfigUpdateListener {
override fun onUpdate(configUpdate: ConfigUpdate) {
if (configUpdate.updatedKeys.any { it in killKeys }) action()
}
override fun onError(error: FirebaseRemoteConfigException) { /* the next foreground fetch repairs it */ }
})
}
}
7. Start, foreground and the splash screen
The Application class builds the store and starts it. ProcessLifecycleOwner reports the foreground. Its stop events come with a delay, so a screen turn does not look like a background trip (ProcessLifecycleOwner).
The first activity holds the splash screen until the local read is done. Android's own sample holds the first frame for small local data, such as settings from disk (Android splash screen). The compat library has setKeepOnScreenCondition for the same job (SplashScreen reference). Do not hold it for the network.
startFinished() runs after five seconds. That number is my choice. It only needs to be longer than the time a bad flag takes to crash the app.
package flags
import android.app.Activity
import android.app.Application
import android.os.Bundle
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.google.firebase.Firebase
import com.google.firebase.remoteconfig.remoteConfig
import com.google.firebase.remoteconfig.remoteConfigSettings
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class FlagsApp : Application() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val ready = AtomicBoolean(false)
lateinit var flagStore: FlagStore
private set
override fun onCreate() {
super.onCreate()
val rc = Firebase.remoteConfig
rc.setConfigSettingsAsync(remoteConfigSettings { minimumFetchIntervalInSeconds = 12 * 3600 })
val source = FirebaseFlagSource(rc)
flagStore = FlagStore(source, DataStoreFlagDisk(this), now = System::currentTimeMillis)
scope.launch {
flagStore.start(userKey = null) // local disk only
ready.set(true)
delay(5_000)
flagStore.startFinished() // the app lived 5 seconds, so it did not crash at start
}
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) { // the app came to the foreground
scope.launch { flagStore.refreshIfStale() }
}
})
source.onKillSwitchChanged { scope.launch { flagStore.refresh() } }
}
}
/** The first activity holds the splash screen until the local read is done. It never waits for the network. */
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
val app = application as FlagsApp
installSplashScreen().setKeepOnScreenCondition { !app.ready.get() }
super.onCreate(savedInstanceState)
}
}
The screens take one view each. CheckoutViewModel keeps its answer until it is cleared. ChatViewModel reads a kill switch each time.
package flags
import androidx.lifecycle.ViewModel
class CheckoutViewModel(store: FlagStore) : ViewModel() {
private val flags = store.view() // one view, taken when the screen opens
val useNewCheckout: Boolean = flags.isOn(Flags.NewCheckout) // the same answer until the screen closes
}
class ChatViewModel(store: FlagStore) : ViewModel() {
private val flags = store.view()
fun canSendMedia(): Boolean = flags.isOn(Flags.ChatMedia) // a kill switch is read each time
}
8. Privacy of targeting data
Targeting needs facts about the user. Each fact is a decision. My default is to send the least.
| Field | Send? | Why |
|---|---|---|
| Platform, app version | Yes | Needed for version rules. Low risk |
| Account bucket number | Yes | One number. It shows no attribute |
| Account ID | Only if the server needs it | It identifies a person. A hash of it still does |
| Email, phone, exact location | No | A rule can use a coarse group that the server already holds |
| Country | No | The server can read it from the request. I did not verify how each vendor does this |
- Apple asks you to declare the data that you and your third-party partners collect (Apple app privacy details). A flag vendor that gets an ID is a partner. Play has a matching Data safety form Verified: [Play Data safety, page fetched 2026-10-14].
- LaunchDarkly private attributes are not stored by LaunchDarkly or sent in events. A client SDK still sends them for evaluation (LaunchDarkly private attributes).
- Flagsmith warns that an identity such as a counting number is easy to guess (Flagsmith identities). Use a random ID.
- Apple says fingerprinting with UserDefaults is not allowed (Apple UserDefaults). Do not use flags to tag a device.
9. Testing flags
A flag doubles the paths in a feature. Test both. The store takes fakes for the source and the disk, so a test needs no network and no vendor.
The tests below cover the cases that hurt in production:
- no network, no saved copy and a failed fetch
- a changed user, a crash loop and a clock set back
- an expired flag and the bucket rules
They ran on the JVM.
package flags
import java.time.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
class FakeSource(var result: Map<String, Boolean>? = null) : FlagSource {
var calls = 0
override suspend fun fetch(userKey: String?): Map<String, Boolean>? { calls++; return result }
}
class FakeDisk(var snapshot: FlagSnapshot? = null, var unfinished: Int = 0) : FlagDisk {
override suspend fun read() = snapshot
override suspend fun write(snapshot: FlagSnapshot) { this.snapshot = snapshot }
override suspend fun clear() { snapshot = null }
override suspend fun unfinishedStarts() = unfinished
override suspend fun setUnfinishedStarts(n: Int) { unfinished = n }
}
class FlagStoreTest {
private var clock = 1_000_000L
private fun store(source: FlagSource, disk: FlagDisk) = FlagStore(source, disk, { clock })
private val saved = FlagSnapshot("u1", 500_000L, mapOf("new_checkout" to true, "chat_media_enabled" to true))
@Test fun firstLaunchOfflineUsesCodeDefaults() = runTest {
val s = store(FakeSource(null), FakeDisk())
s.start("u1")
val v = s.view()
assertFalse(v.isOn(Flags.NewCheckout)) // default false
assertTrue(v.isOn(Flags.ChatMedia)) // default true
}
@Test fun savedValuesApplyAtStartAndNewOnesWaitForNextSession() = runTest {
val src = FakeSource(mapOf("new_checkout" to false, "chat_media_enabled" to true))
val disk = FakeDisk(saved)
val s = store(src, disk)
s.start("u1")
val screen = s.view()
assertTrue(screen.isOn(Flags.NewCheckout))
s.refresh()
assertTrue(screen.isOn(Flags.NewCheckout)) // this screen does not flicker
assertTrue(s.view().isOn(Flags.NewCheckout)) // same session, same answer
assertFalse(disk.snapshot!!.values.getValue("new_checkout")) // next start sees false
}
@Test fun killSwitchChangesNow() = runTest {
val s = store(FakeSource(mapOf("chat_media_enabled" to false)), FakeDisk(saved))
s.start("u1")
val screen = s.view()
assertTrue(screen.isOn(Flags.ChatMedia))
s.refresh()
assertFalse(screen.isOn(Flags.ChatMedia))
}
@Test fun failedFetchKeepsLastValues() = runTest {
val disk = FakeDisk(saved)
val s = store(FakeSource(null), disk)
s.start("u1"); s.refresh()
assertEquals(saved, disk.snapshot)
assertTrue(s.view().isOn(Flags.NewCheckout))
}
@Test fun otherUsersValuesAreNeverShown() = runTest {
val s = store(FakeSource(null), FakeDisk(saved))
s.start("u2")
assertFalse(s.view().isOn(Flags.NewCheckout))
s.switchUser("u1")
assertTrue(s.view().isOn(Flags.NewCheckout))
s.switchUser(null)
assertFalse(s.view().isOn(Flags.NewCheckout))
}
@Test fun twoUnfinishedStartsMeanSafeMode() = runTest {
val disk = FakeDisk(saved, unfinished = 2)
val s = store(FakeSource(null), disk)
s.start("u1")
assertFalse(s.view().isOn(Flags.NewCheckout))
assertEquals(null, disk.snapshot)
s.startFinished()
assertEquals(0, disk.unfinished)
}
@Test fun clockMovedBackStillFetches() = runTest {
val src = FakeSource(mapOf("new_checkout" to true))
val s = store(src, FakeDisk(saved.copy(fetchedAtMillis = clock + 9_999_999L)))
s.start("u1")
s.refreshIfStale()
assertEquals(1, src.calls)
}
@Test fun freshSnapshotIsNotFetchedAgain() = runTest {
val src = FakeSource(mapOf("new_checkout" to true))
val s = store(src, FakeDisk(saved.copy(fetchedAtMillis = clock - 60_000L)))
s.start("u1")
s.refreshIfStale()
assertEquals(0, src.calls)
}
@Test fun noFlagIsPastItsExpiry() {
// Use the real date in CI. A fixed date here keeps this example repeatable.
assertEquals(emptyList(), Flags.expired(LocalDate.of(2026, 10, 14)))
assertEquals(listOf(Flags.NewCheckout), Flags.expired(LocalDate.of(2027, 3, 2)))
}
@Test fun sameAccountSameBucket() {
assertEquals(Bucket.of("acct-42", "new_checkout"), Bucket.of("acct-42", "new_checkout"))
}
@Test fun androidAndIosAgree() {
assertEquals(1500, Bucket.of("acct-42", "new_checkout")) // the Swift version prints the same number
}
@Test fun biggerPercentageOnlyAddsPeople() {
val ids = (1..20_000).map { "acct-$it" }
val ten = ids.filter { Bucket.isIn(it, "new_checkout", 1_000) }.toSet()
val twenty = ids.filter { Bucket.isIn(it, "new_checkout", 2_000) }.toSet()
assertTrue(twenty.containsAll(ten))
assertTrue(ten.size in 1_800..2_200, "ten percent of 20,000 was ${ten.size}")
}
@Test fun flagsPickTheirPeopleIndependently() {
val ids = (1..20_000).map { "acct-$it" }
val a = ids.filter { Bucket.isIn(it, "flag_a", 1_000) }.toSet()
val b = ids.filter { Bucket.isIn(it, "flag_b", 1_000) }.toSet()
assertTrue((a intersect b).size < 400, "overlap was ${(a intersect b).size}")
}
}
More rules, all my opinion:
- Run each screen test with the flag on and with it off.
- Put a debug-only screen on the store to force a value. Never ship it in a release build.
- Run the expiry check in CI with the real date. The test above uses a fixed date so the example stays repeatable.
- Vendors help here. LaunchDarkly offline mode makes no network requests, which suits unit tests (LaunchDarkly offline mode). OpenFeature's client SDKs wait for a provider to be ready before they evaluate (OpenFeature Kotlin SDK).
10. Flag cleanup
Old flags are debt. Unleash says stale flags make code harder to read, and can expose features by mistake (Unleash technical debt). Unleash lists expected lifetimes by type. These are the numbers it gives (feature flags).
| Type | Unleash lifetime | My use |
|---|---|---|
| Release | 40 days | A new feature rollout |
| Experiment | 40 days | Parked in this case |
| Operational | 7 days | A switch for a migration |
| Kill switch | Permanent | Marked in code, no date |
| Sunset | 90 days | Removing an old feature |
The cleanup steps:
- The rollout reaches 100 percent and stays there for a set time.
- Remove the old code path. Keep serving the flag.
- Remove the flag from the code and from the flag list.
- Archive it in the dashboard. Never reuse the key. Unleash gives unique names as a reason: reuse can turn on an old feature (best practices).
Order matters. After a LaunchDarkly archive, an app that still asks for the flag gets the fallback value from the code (LaunchDarkly archiving). So remove the code first.
11. Platform limits
Each cell restates a fact from this guide or from the interview page.
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| Live updates | Firebase real-time runs in the foreground and stops in the background | Same Firebase behavior | Kill switches use live updates and also the next foreground fetch |
| Waking the app | Doze suspends network access on an idle phone | Background push is low priority, not guaranteed, two or three an hour at most | A push is an extra, never the plan |
| Foreground signal | ProcessLifecycleOwner, with delayed stop events | ScenePhase active | Refresh when the saved copy is old |
| Saved copy | DataStore, transactional | UserDefaults, thread-safe, written to disk later | One record. The next fetch repairs a lost write |
| Start screen | Hold the splash screen only for local data | I did not read an Apple number for this | Never wait for the network at start |
| Throttle | Fetch can be throttled | Fetch can be throttled | A failed fetch keeps the old values |
| Store release | Play staged rollout can be halted | Phased release can be paused up to 30 days | Neither recalls a build. The flag is the rollback |
12. How to build feature flags on iOS (Swift)
The design carries over: a store with a saved copy, defaults in code, one decision per session, and a live kill switch. Only the tools change.
| Concern | Android | iOS |
|---|---|---|
| Saved copy | DataStore | UserDefaults (one key, JSON) |
| Store | Class made in Application | @MainActor class |
| Foreground signal | ProcessLifecycleOwner | ScenePhase in SwiftUI |
| Vendor SDK | firebase-config | FirebaseRemoteConfig |
| Bucket hash | MessageDigest | CryptoKit SHA256 |
What really changes:
- The store runs on the main actor. A view can call
isOnin its body without anawait. The disk and the source are async protocols, so slow work does not block the main thread. - A refresh can overlap a user switch.
refresh()drops the answer if the user changed while it waited. A late write cannot leak either, because the saved copy carries a user key, and a mismatch is ignored at start. - The foreground signal is the scene phase.
activemeans the scene is in the foreground and interactive (Apple ScenePhase). - The first screen waits for the local read.
RootViewshows an empty view for a few milliseconds.
import CryptoKit
import Foundation
/// A flag, declared in code. `defaultValue` is the behavior that already shipped and worked.
/// A kill switch has no expiry date. Every other flag must have one.
struct Flag: Sendable {
let key: String
let defaultValue: Bool
let owner: String
let expires: Date?
let killSwitch: Bool
init(key: String, defaultValue: Bool, owner: String, expires: Date? = nil, killSwitch: Bool = false) {
precondition(killSwitch || expires != nil, "\(key) needs an expiry date")
self.key = key; self.defaultValue = defaultValue; self.owner = owner
self.expires = expires; self.killSwitch = killSwitch
}
}
enum Flags {
static func day(_ y: Int, _ m: Int, _ d: Int) -> Date {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "UTC")!
return c.date(from: DateComponents(year: y, month: m, day: d))!
}
static let newCheckout = Flag(key: "new_checkout", defaultValue: false, owner: "payments", expires: day(2027, 3, 1))
static let chatMedia = Flag(key: "chat_media_enabled", defaultValue: true, owner: "chat", killSwitch: true)
static let all = [newCheckout, chatMedia]
/// Run this in CI. A flag past its date fails the build until someone removes it.
static func expired(on today: Date) -> [Flag] {
all.filter { ($0.expires ?? .distantFuture) < today }
}
}
/// Rollout bucket, 0 to 9,999. It runs on the server. It is shown here so the property can be tested:
/// the same account and flag always give the same bucket, and a larger percentage only adds buckets.
enum Bucket {
static func of(stableID: String, flagKey: String) -> Int {
let digest = SHA256.hash(data: Data("\(flagKey):\(stableID)".utf8))
let first4 = digest.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
return Int(first4 % 10_000)
}
/// `basisPoints` 1,000 means 10 percent.
static func isIn(stableID: String, flagKey: String, basisPoints: Int) -> Bool {
of(stableID: stableID, flagKey: flagKey) < basisPoints
}
}
import Foundation
struct FlagSnapshot: Codable, Equatable, Sendable {
var userKey: String? // nil means signed out
var fetchedAt: TimeInterval // seconds since 1970. 0 means never fetched
var values: [String: Bool]
static func empty(_ userKey: String?) -> FlagSnapshot { .init(userKey: userKey, fetchedAt: 0, values: [:]) }
}
protocol FlagSource: Sendable {
/// Returns the values for this user, or nil when the fetch failed.
func fetch(userKey: String?) async -> [String: Bool]?
}
protocol FlagDisk: Sendable {
func read() async -> FlagSnapshot?
func write(_ snapshot: FlagSnapshot) async
func clear() async
func unfinishedStarts() async -> Int
func setUnfinishedStarts(_ n: Int) async
}
@MainActor
final class FlagStore {
private let source: FlagSource
private let disk: FlagDisk
private let now: @Sendable () -> TimeInterval
private let maxAge: TimeInterval
private let killKeys: Set<String>
private var userKey: String?
private var session = FlagSnapshot.empty(nil) // fixed for the whole session
private var live: [String: Bool] = [:] // kill switches only
private var lastFetchAt: TimeInterval = 0
init(source: FlagSource, disk: FlagDisk, now: @escaping @Sendable () -> TimeInterval,
flags: [Flag] = Flags.all, maxAge: TimeInterval = 15 * 60) {
self.source = source; self.disk = disk; self.now = now; self.maxAge = maxAge
self.killKeys = Set(flags.filter(\.killSwitch).map(\.key))
}
/// Call once, before the first screen. It reads local disk only. It never waits for the network.
func start(userKey: String?) async {
self.userKey = userKey
let unfinished = await disk.unfinishedStarts()
await disk.setUnfinishedStarts(unfinished + 1)
let saved = await disk.read()
if unfinished >= 2 { // two starts never finished: safe mode
await disk.clear()
session = .empty(userKey)
} else if let saved, saved.userKey == userKey {
session = saved
} else {
session = .empty(userKey)
}
lastFetchAt = session.fetchedAt
live = session.values.filter { killKeys.contains($0.key) }
}
/// Call a few seconds after the first screen. It resets the crash counter.
func startFinished() async { await disk.setUnfinishedStarts(0) }
/// A screen takes one view when it opens. The answers stay the same until the screen closes.
func view() -> FlagView { FlagView(values: session.values, store: self) }
fileprivate func liveValue(_ flag: Flag) -> Bool { live[flag.key] ?? flag.defaultValue }
func refreshIfStale() async {
let age = now() - lastFetchAt
if age >= 0 && age < maxAge { return } // a negative age means the clock moved back: fetch
await refresh()
}
func refresh() async {
let key = userKey
guard let fresh = await source.fetch(userKey: key) else { return } // failed: keep what we have
guard key == userKey else { return } // the user changed while we waited
lastFetchAt = now()
await disk.write(FlagSnapshot(userKey: key, fetchedAt: lastFetchAt, values: fresh)) // the next session reads this
if key == userKey { live = fresh.filter { killKeys.contains($0.key) } } // kill switches change now
}
/// Sign in or out. The old user's values must not reach the new user.
func switchUser(_ newKey: String?) async {
userKey = newKey
let saved = await disk.read()
session = (saved?.userKey == newKey ? saved : nil) ?? .empty(newKey)
lastFetchAt = session.fetchedAt
live = session.values.filter { killKeys.contains($0.key) }
await refresh()
}
}
@MainActor
struct FlagView {
fileprivate let values: [String: Bool]
fileprivate let store: FlagStore
func isOn(_ flag: Flag) -> Bool {
flag.killSwitch ? store.liveValue(flag) : (values[flag.key] ?? flag.defaultValue)
}
}
import Foundation
/// One key holds the whole snapshot, so a reader never sees half of it.
/// UserDefaults is thread-safe. It writes to disk later, so a very recent write can be lost if the app is killed.
struct UserDefaultsFlagDisk: FlagDisk, @unchecked Sendable {
var defaults: UserDefaults = .standard
private let snapshotKey = "flags.snapshot.v1" // change the number if FlagSnapshot changes
private let unfinishedKey = "flags.unfinishedStarts"
func read() async -> FlagSnapshot? {
guard let data = defaults.data(forKey: snapshotKey) else { return nil }
return try? JSONDecoder().decode(FlagSnapshot.self, from: data)
}
func write(_ snapshot: FlagSnapshot) async {
if let data = try? JSONEncoder().encode(snapshot) { defaults.set(data, forKey: snapshotKey) }
}
func clear() async { defaults.removeObject(forKey: snapshotKey) }
func unfinishedStarts() async -> Int { defaults.integer(forKey: unfinishedKey) }
func setUnfinishedStarts(_ n: Int) async { defaults.set(n, forKey: unfinishedKey) }
}
import SwiftUI
struct RootView: View {
let store: FlagStore
@State private var ready = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
if ready { CheckoutScreen(store: store) } else { Color.clear } // the local read takes a few milliseconds
}
.task {
await store.start(userKey: nil) // local disk only
ready = true
await store.refreshIfStale() // cold start: fetch only if the saved copy is old
try? await Task.sleep(for: .seconds(5))
await store.startFinished()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { Task { await store.refreshIfStale() } } // back in the foreground
}
}
}
struct CheckoutScreen: View {
@State private var flags: FlagView
init(store: FlagStore) {
_flags = State(initialValue: store.view()) // one view, taken when the screen opens
}
var body: some View {
Text(flags.isOn(Flags.newCheckout) ? "New checkout" : "Checkout")
}
}
The Firebase adapter below was not compiled, because the Firebase iOS SDK was not on this machine. I took each call from the Firebase Swift reference: fetch() and activate() as async throwing calls, allKeys(from:) with .remote, configValue(forKey:) and setCustomSignals(_:) (Firebase Swift reference). Compile it before you ship it.
import FirebaseRemoteConfig
// Not compiled for this guide: the Firebase iOS SDK was not on this machine.
struct FirebaseFlagSource: FlagSource {
func fetch(userKey: String?) async -> [String: Bool]? {
let rc = RemoteConfig.remoteConfig()
do {
_ = try await rc.fetch() // respects minimumFetchInterval
_ = try await rc.activate() // moves Firebase's own copy forward. Screens read FlagStore.
// Keep only values that came from the server. For the rest, the Flag default in code applies.
let remote = Set(rc.allKeys(from: .remote))
return Dictionary(uniqueKeysWithValues: Flags.all.filter { remote.contains($0.key) }
.map { ($0.key, rc.configValue(forKey: $0.key).boolValue) })
} catch {
return nil // throttled, offline, timeout
}
}
/// Send a rollout bucket that follows the account, not the install. A console rule can say bucket < 1000.
func setBucket(accountID: String?) async {
let bucket = accountID.map { Bucket.of(stableID: $0, flagKey: "all") } ?? -1
try? await RemoteConfig.remoteConfig().setCustomSignals(["rollout_bucket": .integer(bucket)])
}
}
The Swift tests use the same cases as the Kotlin ones. They ran as a small macOS program, and all checks passed.
import Foundation
final class Counter: @unchecked Sendable { var n = 0 }
final class FakeSource: FlagSource, @unchecked Sendable {
var result: [String: Bool]?
let calls = Counter()
init(_ r: [String: Bool]? = nil) { result = r }
func fetch(userKey: String?) async -> [String: Bool]? { calls.n += 1; return result }
}
actor FakeDisk: FlagDisk {
var snapshot: FlagSnapshot?
var unfinished = 0
init(_ s: FlagSnapshot? = nil, unfinished: Int = 0) { snapshot = s; self.unfinished = unfinished }
func read() -> FlagSnapshot? { snapshot }
func write(_ s: FlagSnapshot) { snapshot = s }
func clear() { snapshot = nil }
func unfinishedStarts() -> Int { unfinished }
func setUnfinishedStarts(_ n: Int) { unfinished = n }
func current() -> FlagSnapshot? { snapshot }
func unfinishedNow() -> Int { unfinished }
}
var failures = 0, checks = 0
@MainActor func check(_ ok: Bool, _ what: String) { checks += 1; if !ok { failures += 1; print("FAIL:", what) } }
@MainActor func run() async {
let clock: TimeInterval = 1_000_000
let saved = FlagSnapshot(userKey: "u1", fetchedAt: 500_000, values: ["new_checkout": true, "chat_media_enabled": true])
func make(_ src: FlagSource, _ disk: FlagDisk) -> FlagStore { FlagStore(source: src, disk: disk, now: { clock }) }
do { // first launch offline uses code defaults
let s = make(FakeSource(nil), FakeDisk()); await s.start(userKey: "u1")
let v = s.view()
check(!v.isOn(Flags.newCheckout), "default false"); check(v.isOn(Flags.chatMedia), "default true")
}
do { // saved values apply at start; new ones wait for the next session
let disk = FakeDisk(saved)
let s = make(FakeSource(["new_checkout": false, "chat_media_enabled": true]), disk)
await s.start(userKey: "u1")
let screen = s.view()
check(screen.isOn(Flags.newCheckout), "saved value")
await s.refresh()
check(screen.isOn(Flags.newCheckout), "no flicker"); check(s.view().isOn(Flags.newCheckout), "same session, same answer")
check(await disk.current()?.values["new_checkout"] == false, "next start sees false")
}
do { // kill switch changes now
let s = make(FakeSource(["chat_media_enabled": false]), FakeDisk(saved)); await s.start(userKey: "u1")
let screen = s.view(); check(screen.isOn(Flags.chatMedia), "on before")
await s.refresh(); check(!screen.isOn(Flags.chatMedia), "kill switch applied now")
}
do { // failed fetch keeps last values
let disk = FakeDisk(saved); let s = make(FakeSource(nil), disk); await s.start(userKey: "u1"); await s.refresh()
check(await disk.current() == saved, "disk unchanged"); check(s.view().isOn(Flags.newCheckout), "still on")
}
do { // other users' values are never shown
let s = make(FakeSource(nil), FakeDisk(saved)); await s.start(userKey: "u2")
check(!s.view().isOn(Flags.newCheckout), "u2 sees defaults")
await s.switchUser("u1"); check(s.view().isOn(Flags.newCheckout), "u1 sees u1")
await s.switchUser(nil); check(!s.view().isOn(Flags.newCheckout), "signed out sees defaults")
}
do { // two unfinished starts mean safe mode
let disk = FakeDisk(saved, unfinished: 2); let s = make(FakeSource(nil), disk); await s.start(userKey: "u1")
check(!s.view().isOn(Flags.newCheckout), "safe mode"); check(await disk.current() == nil, "snapshot cleared")
await s.startFinished(); check(await disk.unfinishedNow() == 0, "counter reset")
}
do { // clock moved back still fetches; a fresh snapshot does not
let a = FakeSource(["new_checkout": true])
var future = saved; future.fetchedAt = clock + 9_999_999
let s1 = make(a, FakeDisk(future)); await s1.start(userKey: "u1"); await s1.refreshIfStale()
check(a.calls.n == 1, "clock back fetches")
let b = FakeSource(["new_checkout": true])
var fresh = saved; fresh.fetchedAt = clock - 60
let s2 = make(b, FakeDisk(fresh)); await s2.start(userKey: "u1"); await s2.refreshIfStale()
check(b.calls.n == 0, "fresh is not fetched again")
}
do { // flags past their date
check(Flags.expired(on: Flags.day(2026, 10, 14)).isEmpty, "none expired today")
check(Flags.expired(on: Flags.day(2027, 3, 2)).count == 1, "one expired later")
}
do { // buckets
check(Bucket.of(stableID: "acct-42", flagKey: "new_checkout") == Bucket.of(stableID: "acct-42", flagKey: "new_checkout"), "stable")
let ids = (1...20_000).map { "acct-\($0)" }
let ten = Set(ids.filter { Bucket.isIn(stableID: $0, flagKey: "new_checkout", basisPoints: 1_000) })
let twenty = Set(ids.filter { Bucket.isIn(stableID: $0, flagKey: "new_checkout", basisPoints: 2_000) })
check(ten.isSubset(of: twenty), "bigger percentage only adds people"); check((1_800...2_200).contains(ten.count), "about ten percent: \(ten.count)")
let a = Set(ids.filter { Bucket.isIn(stableID: $0, flagKey: "flag_a", basisPoints: 1_000) })
let b = Set(ids.filter { Bucket.isIn(stableID: $0, flagKey: "flag_b", basisPoints: 1_000) })
check(a.intersection(b).count < 400, "flags are independent: \(a.intersection(b).count)")
print("bucket acct-42/new_checkout =", Bucket.of(stableID: "acct-42", flagKey: "new_checkout"))
}
}
await run()
print("\(checks) checks, \(failures) failures")
exit(failures == 0 ? 0 : 1)
13. More practice prompts
- How do you show a screen that depends on a flag before the first fetch? (Use the saved copy or the default. Never a spinner for the network.)
- How would you let support see which flags one user has? (Log the flag version and the bucket, not the user's attributes.)
- How do you know a flag is safe to remove? (Zero reads in the last release, the rollout at 100 percent, and the old code path deleted.)
- How would you move from Firebase to another vendor? (The store and the flag list stay. Only the source adapter changes. OpenFeature offers a standard provider interface (OpenFeature providers).)
Further reading (official docs)
- The interview part of this case: scope, decisions, failures, follow-ups and levels
- Remote Config loading strategies (Firebase)
- Optimize fetch frequency (Firebase)
- Real-time Remote Config (Firebase)
- Parameters and conditions in client templates (Firebase)
- Manage template versions (Firebase)
- Percentage rollouts (LaunchDarkly)
- Client-side and mobile SDKs (LaunchDarkly)
- Stickiness and technical debt (Unleash)
- Evaluation context and SDK paradigms (OpenFeature)
- DataStore (Android)
- ProcessLifecycleOwner (Android)
- ScenePhase (Apple)
- App Review Guidelines (Apple)
Words on this page (11)
- Actor
- A Swift type that runs one task at a time. This keeps its data safe when many tasks use it.
- Doze
- The deep-sleep mode of an idle Android phone. It pauses network access and delays background work.
- Hash
- A short code made from the bytes of a file. Apps compare the codes to see if two files are the same.
- Kill switch
- A remote flag that turns a feature off at once. It is the one rollback that reaches apps that are already installed.
- Main thread
- The thread that draws the screen. If slow work runs on it, the screen freezes.
- Rollback
- Going back to the old behavior after a problem. A build on a user's phone cannot be taken back, so teams use a kill switch.
- Rollout bucket
- A number that the server works out from a user ID and a flag key. A flag set to 10% is on for the users with the lowest 10% of the numbers.
- SDK
- Software development kit. Code that other developers add to their own apps.
- Snapshot
- A copy of the data as it was at one moment. It is not a list of changes.
- Staged rollout
- A release to a growing share of users, not to everyone at once. It limits the harm of a bad release.
- Stale data
- Saved data that may be out of date. The app should show how old it is, or refresh it.
Verification notes (last reviewed 14 Oct 2026)
this guide holds the build material for the feature flags case. The code is new. Everything on this page was written and checked on 2026-10-14. Compile checks. The Kotlin core (Flag, Bucket, FlagStore) and its 13 tests ran on the JVM with Gradle 9.8.1 and Kotlin 2.4.20 (kotlinx-coroutines 1.10.2). All 13 passed. The Android glue (DataStoreFlagDisk, FirebaseFlagSource, FlagsApp, Screens) was compiled with compileDebugKotlin using Android Gradle Plugin 9.4.1, compileSdk 36, minSdk 26, Firebase BoM 34.19.0 (firebase-config 23.1.0), lifecycle 2.9.4, DataStore 1.2.1 and core-splashscreen 1.2.0. It was built offline against the local Gradle cache. I had to force androidx.core 1.16.0 and firebase-measurement-connector 20.0.1 to versions that were cached, so a normal build may resolve slightly different versions. Not done: the glue was not run on a device or emulator, so the DataStore write, the Firebase calls, the real-time listener, the splash hold and the Application wiring are compile-checked only. Not compiled: the MainActivity has no manifest entry or theme in this project. The Swift files Flags, FlagStore, UserDefaultsFlagDisk and RootView were type-checked in Swift language modes 5 and 6 against the macOS SDK and the iOS 27.0 simulator SDK (target iOS 17), with Swift 6.4. The Swift tests were built and run as a macOS program with the Flags, FlagStore and UserDefaultsFlagDisk files, and 24 checks passed. RootView was only type-checked. FirebaseFlagSource.swift was not compiled and not run. The Kotlin and Swift Bucket functions give 1500 for acct-42 and new_checkout. Opinion, not verified: the crash guard, the 15 minute age, the 5 second finish delay, the idea of one record per snapshot, the privacy table choices, the test rules and the lifetimes in my use column. Facts I read and where: the Firebase pages for loading strategies, fetch frequency, real-time, get started, client templates, condition reference, quotas and the Android and Swift references; the LaunchDarkly pages for SDK types, percentage rollouts, private attributes, offline mode and archiving; the Unleash pages for best practices, stickiness, feature flags and technical debt; the Flagsmith identities page; the OpenFeature Kotlin, provider and paradigms pages; the Android DataStore, ProcessLifecycleOwner, splash screen and SplashScreen reference pages; the Apple ScenePhase, UserDefaults, review guidelines and privacy details pages; the Play Data safety page. The Play Data safety page was fetched but I did not read its rules closely. The Apple launch-time page could not be read, since it returned almost no text, so the iOS start cell says so. Known inferences: that a Firebase reinstall changes the group comes from the page saying each app instance is mapped to a number. The statement that Firebase fetch calls inside the interval do not reach the network is the documented meaning of the minimum fetch interval, and I did not measure it. I did not test a crash during a DataStore write. The Flagsmith and Unleash pages are vendor docs, not neutral sources. The Unleash expected lifetimes are its defaults, not a standard. No count of sources is claimed.
Keep reading
Get the free one-page cheat sheet
One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.
We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.