When Uber's mobile team built Piranha in 2019, they were not solving an abstract engineering problem. They were drowning. Their iOS and Android apps had accumulated thousands of stale feature flags -- dead conditionals controlling code paths that would never execute again, inflating binary size, slowing build times, and creating a labyrinth of branching logic that made every code change risky. Piranha, their automated flag cleanup tool, ultimately removed around 2,000 stale flags from Uber's mobile apps. The tool became so critical that they open-sourced it, and its origin story reveals a truth that every mobile engineering team eventually confronts: feature flag debt hits mobile apps harder than any other platform.
The reasons are structural, not cultural. Mobile apps operate under constraints that web services do not. Every stale flag contributes to binary size that users download. Every dead code branch passes through app store review pipelines that take days, not minutes. Every flag interaction increases the combinatorial state space in an environment where you cannot hotfix -- once a build ships, millions of users are running it until they choose to update. In web services, a stale flag wastes server cycles. In mobile apps, a stale flag wastes user bandwidth, device storage, battery life, and your team's ability to ship quickly.
Why mobile is different: The structural case
Binary size is a first-class concern
Web services deploy to servers you control. A few thousand lines of dead code behind stale flags costs fractional CPU cycles. Mobile apps deploy to devices owned by users with limited storage, metered data plans, and strong opinions about which apps deserve space on their home screen.
The binary size impact of stale flags:
| Platform | Dead Code Per Stale Flag | 50 Stale Flags Impact | User Impact |
|---|---|---|---|
| iOS (Swift) | 5-15 KB compiled | 250-750 KB | App Store download size increase |
| Android (Kotlin) | 3-12 KB compiled | 150-600 KB | Google Play install size increase |
| React Native (JS) | 2-8 KB bundled | 100-400 KB | OTA update size increase |
These numbers may seem small individually, but they compound. A mature mobile app with 100+ stale flags can carry 1-3 MB of dead code. For apps targeting emerging markets where users are on 2G/3G connections and budget devices with 16-32 GB of storage, that overhead directly impacts install rates and retention.
Apple and Google both use app size as a signal. Apple warns users when an app exceeds 200 MB, and Google has published research showing that larger APK sizes correlate with lower install conversion rates. Stale flags contribute to this bloat silently, because the dead code compiles successfully and passes every lint check.
App store review cycles eliminate fast iteration
Web services can deploy a fix in minutes. Mobile releases go through multi-day review cycles:
| Platform | Typical Review Time | Emergency Review | Rollback Strategy |
|---|---|---|---|
| iOS App Store | 24-48 hours | 2-4 hours (expedited) | Submit new build, wait again |
| Google Play | 1-7 days (varies) | Hours for critical fixes | Staged rollout pause, but not instant |
| React Native (OTA) | Minutes (CodePush) | Minutes | Instant rollback via OTA |
When a stale flag interaction causes a bug in production, you cannot patch it by changing a server-side configuration. You must build a new binary, submit it for review, wait for approval, and then hope users update. This is why mobile teams use feature flags aggressively in the first place -- they provide a remote kill switch for features that cannot be hotfixed. But the irony is that each flag added for safety creates long-term debt that makes future changes riskier.
Users run multiple versions simultaneously
Web services have one production version. Mobile apps have dozens of versions in the wild simultaneously:
Active user distribution (typical mature app):
- Latest version (v5.2.0): 45%
- Previous version (v5.1.0): 25%
- Two versions back (v5.0.0): 15%
- Three+ versions back: 15%
This creates a unique challenge for flag cleanup. When you remove a flag from the server-side configuration, you must ensure that all active app versions handle the missing flag gracefully. A flag that was evaluated client-side on v5.0.0 might cause a crash on v5.0.0 devices if the server stops providing it.
This is why mobile flag cleanup must be coordinated across three systems: the mobile codebase, the server-side configuration, and the minimum supported app version.
Swift: Flag patterns and cleanup
Common Swift flag patterns
Pattern 1: SDK-based evaluation with property wrappers
class CheckoutViewController: UIViewController {
@FeatureFlag("new-checkout-flow")
var isNewCheckoutEnabled: Bool
override func viewDidLoad() {
super.viewDidLoad()
if isNewCheckoutEnabled {
setupNewCheckoutUI()
} else {
setupLegacyCheckoutUI()
}
}
private func setupNewCheckoutUI() {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 16
// 40+ lines of new checkout layout
view.addSubview(stackView)
// constraints setup...
}
private func setupLegacyCheckoutUI() {
let tableView = UITableView()
// 35+ lines of legacy checkout layout
view.addSubview(tableView)
// constraints setup...
}
}
Pattern 2: Protocol-based feature switching
protocol SearchEngine {
func search(query: String) async throws -> [SearchResult]
func suggest(partial: String) async throws -> [String]
}
class LegacySearchEngine: SearchEngine {
func search(query: String) async throws -> [SearchResult] {
// SQLite-based full-text search
let db = try SQLiteDatabase.shared()
return try await db.fullTextSearch(query)
}
func suggest(partial: String) async throws -> [String] {
// Prefix matching on cached terms
return cachedTerms.filter { $0.hasPrefix(partial) }
}
}
class ElasticSearchEngine: SearchEngine {
func search(query: String) async throws -> [SearchResult] {
// Elasticsearch API call
let request = SearchRequest(query: query, size: 20)
return try await apiClient.execute(request)
}
func suggest(partial: String) async throws -> [String] {
// Elasticsearch completion suggester
let request = SuggestRequest(prefix: partial)
return try await apiClient.execute(request)
}
}
// Factory with flag
class SearchEngineFactory {
static func create() -> SearchEngine {
if FeatureFlags.shared.isEnabled("elasticsearch-backend") {
return ElasticSearchEngine()
}
return LegacySearchEngine()
}
}
Pattern 3: SwiftUI conditional views
struct ProfileView: View {
@FeatureFlag("enhanced-profile") var showEnhancedProfile: Bool
@FeatureFlag("social-features") var showSocialFeatures: Bool
var body: some View {
ScrollView {
VStack(spacing: 20) {
if showEnhancedProfile {
EnhancedProfileHeader(user: user)
ActivityTimeline(user: user)
AchievementBadges(user: user)
} else {
BasicProfileHeader(user: user)
}
if showSocialFeatures {
FriendsList(user: user)
SharedActivity(user: user)
}
// Always shown
SettingsSection()
}
}
}
}
Swift cleanup techniques
Removing a flag from a UIKit view controller:
// Before: Two setup methods, flag check in viewDidLoad
// After: Single setup method, no flag check
class CheckoutViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupCheckoutUI() // Renamed from setupNewCheckoutUI
}
private func setupCheckoutUI() {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 16
view.addSubview(stackView)
// constraints...
}
// DELETE: setupLegacyCheckoutUI()
// DELETE: @FeatureFlag property wrapper
}
Removing a protocol-based flag:
// After cleanup: Remove the factory, interface may no longer be needed
// DELETE: LegacySearchEngine class entirely
// DELETE: SearchEngineFactory class
// If only one implementation remains, consider removing the protocol
// and using the concrete type directly:
class SearchEngine {
func search(query: String) async throws -> [SearchResult] {
let request = SearchRequest(query: query, size: 20)
return try await apiClient.execute(request)
}
}
Swift-specific cleanup verification:
// After removing flags, verify with Xcode build settings:
// 1. Build with -Wunused-function to catch orphaned methods
// 2. Check for "unused import" warnings
// 3. Run Instruments > Allocations to verify dead code isn't loaded
// Use Swift's #warning directive during cleanup:
#warning("TODO: Remove LegacySearchEngine after v5.3 minimum version bump")
Kotlin: Flag patterns and cleanup
Common Kotlin flag patterns
Pattern 1: Sealed class feature variants
sealed class PaymentFlow {
data class Legacy(val config: LegacyPaymentConfig) : PaymentFlow()
data class Modern(val config: ModernPaymentConfig) : PaymentFlow()
}
class PaymentFlowFactory @Inject constructor(
private val featureFlags: FeatureFlagClient,
private val legacyConfig: LegacyPaymentConfig,
private val modernConfig: ModernPaymentConfig,
) {
fun create(): PaymentFlow {
return if (featureFlags.isEnabled("modern-payment-flow")) {
PaymentFlow.Modern(modernConfig)
} else {
PaymentFlow.Legacy(legacyConfig)
}
}
}
// Consumers use exhaustive when:
fun processPayment(flow: PaymentFlow, amount: Money): Receipt {
return when (flow) {
is PaymentFlow.Legacy -> processLegacyPayment(flow.config, amount)
is PaymentFlow.Modern -> processModernPayment(flow.config, amount)
}
}
Pattern 2: Dagger/Hilt module switching
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideHttpClient(featureFlags: FeatureFlagClient): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
if (featureFlags.isEnabled("http2-multiplexing")) {
builder.protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE, Protocol.HTTP_1_1))
} else {
builder.protocols(listOf(Protocol.HTTP_1_1))
}
if (featureFlags.isEnabled("certificate-pinning-v2")) {
builder.certificatePinner(createV2CertificatePinner())
} else {
builder.certificatePinner(createV1CertificatePinner())
}
return builder.build()
}
}
Pattern 3: Compose conditional UI
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val flags by viewModel.featureFlags.collectAsState()
LazyColumn {
item { AccountSection() }
if (flags.isEnabled("notification-preferences-v2")) {
item { NotificationPreferencesV2() }
} else {
item { NotificationPreferencesV1() }
}
if (flags.isEnabled("data-export")) {
item { DataExportSection() }
}
item { AboutSection() }
}
}
Kotlin cleanup techniques
Removing a sealed class variant:
// After cleanup: Collapse sealed class if only one variant remains
// Before: Sealed class with two variants
// After: Direct usage of the surviving config
class PaymentFlowFactory @Inject constructor(
private val modernConfig: ModernPaymentConfig,
) {
fun create(): ModernPaymentConfig = modernConfig
}
// Simplify consumers:
fun processPayment(config: ModernPaymentConfig, amount: Money): Receipt {
return processModernPayment(config, amount)
}
// DELETE: PaymentFlow sealed class
// DELETE: LegacyPaymentConfig class
// DELETE: processLegacyPayment function
The sealed class pattern is particularly interesting in Kotlin because Kotlin's exhaustive when expressions force you to handle every variant. When you delete a sealed class variant, the compiler immediately flags every when expression that referenced it. This makes Kotlin one of the safest languages for flag cleanup -- the compiler guides you through every call site.
Cleaning up Dagger modules:
// After cleanup: Remove flag checks from module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE, Protocol.HTTP_1_1))
.certificatePinner(createV2CertificatePinner())
.build()
}
}
// DELETE: FeatureFlagClient injection (if no other flags in this module)
// DELETE: createV1CertificatePinner function
Kotlin-specific cleanup verification:
// Use Kotlin compiler warnings to catch dead code:
// 1. Build with -Xexplicit-api=strict (catches unused public APIs)
// 2. Run detekt with UnusedPrivateMember rule
// 3. Check for "Variable is never used" warnings
// Use @Deprecated during gradual cleanup:
@Deprecated(
message = "Legacy notification preferences. Remove after v5.3 minimum version bump.",
level = DeprecationLevel.WARNING
)
@Composable
fun NotificationPreferencesV1() { /* ... */ }
React Native: The cross-platform flag challenge
React Native adds a unique layer of complexity because flags can exist in JavaScript/TypeScript, native iOS modules, and native Android modules simultaneously.
React Native flag patterns
Pattern 1: JavaScript-side flags with native bridge implications
function CameraScreen() {
const useNewCameraModule = useFeatureFlag('native-camera-v2');
const capturePhoto = useCallback(async () => {
if (useNewCameraModule) {
// Calls the new native module
const photo = await NativeModules.CameraV2.capture({
quality: 0.9,
format: 'heic',
});
return photo;
}
// Calls the legacy native module
const photo = await NativeModules.CameraV1.capture({
quality: 0.8,
format: 'jpeg',
});
return photo;
}, [useNewCameraModule]);
return <CameraView onCapture={capturePhoto} />;
}
The bridge problem: When the JavaScript flag controls which native module is called, removing the flag from JavaScript is not enough. You must also remove the native module registration from both iOS and Android:
// ios/CameraV1Module.swift -- DELETE after flag cleanup
@objc(CameraV1Module)
class CameraV1Module: NSObject {
@objc func capture(_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
// Legacy camera implementation
}
}
// android/src/main/java/com/app/CameraV1Module.kt -- DELETE after flag cleanup
class CameraV1Module(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName() = "CameraV1"
@ReactMethod
fun capture(options: ReadableMap, promise: Promise) {
// Legacy camera implementation
}
}
Pattern 2: OTA-updatable vs native-only flags
// This flag can be cleaned up via OTA update (CodePush/EAS Updates)
function HomeScreen() {
const showNewBanner = useFeatureFlag('promo-banner-v2');
return showNewBanner ? <PromoBannerV2 /> : <PromoBanner />;
}
// This flag requires a native build to clean up
function VideoPlayer() {
const useNativePlayer = useFeatureFlag('native-video-player');
return useNativePlayer
? <NativeVideoPlayer uri={videoUri} /> // Uses native module
: <WebViewPlayer uri={videoUri} />; // Pure JS implementation
}
The distinction between OTA-updatable and native-only flags is critical for cleanup planning. OTA-updatable flags (pure JavaScript) can be cleaned up in minutes via CodePush or EAS Updates. Native-requiring flags need a full app store release cycle.
React Native cleanup strategy
| Flag Type | Cleanup Scope | Release Requirement | Rollback Risk |
|---|---|---|---|
| JS-only UI flag | JavaScript bundle | OTA update (minutes) | Low (instant rollback) |
| JS flag with native bridge | JS + iOS + Android native | App store release (days) | High (no instant rollback) |
| Native-only flag | iOS + Android native | App store release (days) | High |
Mobile-specific testing challenges
Testing flag removal without device farms
Every flag removal must be tested on the platforms the app supports. For mobile, this means:
Minimum test matrix per flag removal:
- iOS: Latest version + N-2
- Android: API level min target through latest
- React Native: iOS + Android + any OTA-specific behavior
With 3 iOS versions, 5 Android API levels, and 2 RN deployment modes:
= 10 test configurations per flag removal
Multiply this by the number of stale flags, and the testing effort becomes substantial. This is why mobile teams tend to batch flag cleanups into dedicated sprints rather than removing flags one at a time.
Strategies to reduce testing burden
1. Feature module boundaries
Structure your app so that flags are contained within feature modules:
// Android feature module structure
:app
:feature:checkout // Contains checkout-v2 flag
:feature:search // Contains search-redesign flag
:feature:notifications // Contains notification-v2 flag
:core:network
:core:analytics
When a flag is removed from :feature:checkout, only that module's tests need to run. The feature module boundary limits the blast radius.
2. Screenshot testing for UI flags
// iOS snapshot test (using swift-snapshot-testing)
func testCheckoutScreenAfterFlagRemoval() {
let viewController = CheckoutViewController()
viewController.loadViewIfNeeded()
assertSnapshot(matching: viewController, as: .image(on: .iPhone13))
assertSnapshot(matching: viewController, as: .image(on: .iPhoneSE))
assertSnapshot(matching: viewController, as: .image(on: .iPadPro11))
}
Screenshot tests catch visual regressions that unit tests miss. After removing a flag that changed the checkout UI, a screenshot test will immediately show if the layout broke.
3. Canary releases for flag cleanup builds
Flag cleanup release strategy:
Day 1: Remove flag code, build, submit to stores
Day 2-3: App store review
Day 4: Release to 1% of users (canary)
Day 5-6: Monitor crash rates, ANR rates, user metrics
Day 7: Expand to 10%, then 50%, then 100%
This staged rollout for flag cleanup releases catches issues before they affect your entire user base.
Coordinating cleanup across platforms
The most challenging aspect of mobile flag cleanup is coordinating across iOS, Android, and backend:
The coordination timeline
Week 1: Prepare
- Identify stale flags in flag service dashboard
- Map references in iOS, Android, React Native, and backend
- Create cleanup tickets for each platform
Week 2: Server-side preparation
- Ensure flag returns hardcoded value (true/false)
- Add monitoring for any remaining flag evaluations
- DO NOT remove the flag from the server yet
Week 3: Mobile cleanup
- iOS: Remove flag code, submit to App Store
- Android: Remove flag code, submit to Google Play
- React Native: Remove JS flag code, prepare OTA update
Week 4: Rollout and verification
- Release mobile updates (staged rollout)
- Monitor crash rates and user metrics
- Verify all active versions handle missing flag gracefully
Week 5: Server-side cleanup
- Once minimum supported version no longer evaluates the flag
- Remove flag from server-side configuration
- Archive flag in management service
The critical rule: Never remove a flag from the server before all active mobile versions stop evaluating it. Violating this rule causes crashes on older app versions that still request the flag value.
Minimum version gating
// Android: Check minimum version before flag cleanup
object FlagCleanupPolicy {
/**
* Flags can only be removed from the server when the minimum
* supported app version no longer evaluates them.
*
* Current minimum: v5.0.0
* Flags safe to remove server-side:
* - checkout-v2 (last evaluated in v4.8.0)
* - search-redesign (last evaluated in v4.9.0)
*
* Flags NOT safe to remove server-side:
* - notification-v2 (still evaluated in v5.0.0)
*/
val SAFE_TO_REMOVE_SERVER_SIDE = setOf(
"checkout-v2",
"search-redesign",
)
}
Measuring mobile flag debt
| Metric | iOS (Swift) | Android (Kotlin) | React Native |
|---|---|---|---|
| Stale flag detection difficulty | Medium (compiler helps) | Medium (compiler helps) | High (dynamic typing) |
| Binary size impact per flag | 5-15 KB | 3-12 KB | 2-8 KB (JS bundle) |
| Cleanup turnaround time | 2-4 days (review) | 1-7 days (review) | Minutes (OTA) to days (native) |
| Testing configurations | 3-5 device classes | 5-10 API levels | iOS + Android + OTA |
| Rollback capability | None (new build required) | Staged rollout pause | Instant (OTA only) |
| Cross-platform coordination | Required | Required | Required |
The compound cost on mobile
Mobile flag debt compounds faster than server-side debt because of the multiplier effect:
| Cost Factor | Server-Side | Mobile (per platform) | Mobile (total for iOS + Android) |
|---|---|---|---|
| Dead code lines per flag | 20-50 | 20-50 | 40-100 |
| Build time impact per flag | Negligible | 1-3 seconds | 2-6 seconds |
| Test configurations per flag | 1-2 | 3-10 | 6-20 |
| Cleanup coordination effort | Single deploy | Multi-day release cycle | Multi-platform multi-day cycle |
| Risk of user-facing regression | Low (instant rollback) | High (no instant rollback) | Very high (no instant rollback, multiple platforms) |
For a mobile team with 50 stale flags across iOS and Android, the build time overhead alone can reach 75-150 seconds -- turning a 5-minute build into a 7-8 minute build. Over hundreds of daily CI builds, that is hours of compute time wasted on code that will never execute.
Automation strategies for mobile
AST-based cleanup tools
Uber's Piranha remains the reference implementation for automated mobile flag cleanup. It uses language-specific parsers to:
- Find flag evaluation call sites
- Determine which branch to keep (based on the flag's permanent value)
- Remove the dead branch and simplify the remaining code
- Generate a pull request with the changes
For teams that need broader coverage across mobile and web codebases, FlagShark takes a similar AST-based approach using tree-sitter grammars for Swift, Kotlin, and TypeScript/React Native. It monitors flag lifecycle from the PR that introduces a flag through to automated cleanup, tracking stale flags across all platforms in a single dashboard.
Integrating cleanup into mobile release trains
Most mobile teams operate on a release train (weekly or biweekly releases). Flag cleanup can be integrated into this cadence:
Release Train Week 1 (Feature Development):
- New flags added with expiration dates
- Flag registry updated
Release Train Week 2 (Stabilization):
- Expired flags identified
- Cleanup PRs generated (manually or via automation)
- Cleanup PRs reviewed and merged
- Integration tests validate cleanup
Release Train Week 3 (Release):
- App submitted with flag cleanup included
- Staged rollout with monitoring
- Server-side flag archival scheduled for min-version gate
This cadence ensures flag cleanup happens every release cycle rather than accumulating into a quarterly debt sprint.
Feature flag debt on mobile is not a theoretical concern -- it is a measurable drag on binary size, build times, test execution, and release velocity. The constraints that make mobile development challenging (app store reviews, version fragmentation, no hotfixes) also make flag debt more expensive to carry and more complex to resolve. The teams that treat mobile flag cleanup as a continuous, cross-platform coordinated effort maintain smaller binaries, faster builds, and the confidence to ship frequently. The teams that defer cleanup until it becomes an emergency find themselves spending entire sprints removing code that should have been cleaned up months ago. Whether you are building in Swift, Kotlin, or React Native, the principle is the same: every flag has a lifecycle, and that lifecycle must include removal.