You've probably felt this already. A new SwiftUI feature is ready enough to merge, but not ready enough to expose to every user. Maybe it touches onboarding, a paywall, or an AI flow that depends on flaky upstream behavior. You want the code in production so you can test with real infrastructure, but you don't want a bad release tied to your next App Store submission.
That's where a solid feature flag implementation changes how you ship. Instead of treating release day as an all-or-nothing event, you decide who sees the feature, when they see it, and how fast exposure grows. For indie iOS apps, that's less about enterprise process and more about survival. You need a way to protect subscriptions, keep crashes contained, and roll back product ideas without cutting a new build.
On the modern indie SwiftUI stack, the pattern is even more useful. Firebase gives you analytics and crash visibility. RevenueCat gives you entitlements and purchase infrastructure. AI coding agents can help you move quickly, but only if the project has clear conventions for where flags live and how they're used. If the flag logic ends up scattered through views, generated code, random helpers, and prompt-driven edits, your app gets messy fast.
Table of Contents
- Choosing Your Feature Flag Architecture
- Integrating a Feature Flag SDK into SwiftUI
- Designing Your Rollout and Targeting Strategy
- Measuring Impact with Firebase and RevenueCat
- Production Readiness and Best Practices
- Ship Faster and Safer with Feature Flags
Choosing Your Feature Flag Architecture
Friday night release. The build is already in review, your new SwiftUI onboarding flow is merged, and the AI summary screen is wired up to a paid backend service. Then a test user finds a bad edge case. You do not want your only rollback plan to be "wait for App Review." You want a flag you can flip in minutes.
That is the primary job of feature flags on iOS. They separate shipping code from exposing behavior. For indie apps, that matters more than it does on the web because every binary change is slower, every hotfix is more expensive, and every mistake can affect conversion, retention, or API spend before a fix lands.
Why flags belong in your release process
Small SwiftUI teams usually adopt flags for the wrong reason first. They want to hide unfinished work. That use case is valid, but it is not the main payoff.
The bigger win is control.
Flags let a team merge early, test against real services, and release pieces of the same build on different timelines. That matters when one App Store submission includes onboarding changes, a paywall refactor, a new AI tool, and a pricing experiment. Those should not all be tied to one release decision.
For subscription apps, there is another line worth keeping sharp. A flag controls experience. RevenueCat or your backend controls access. If a user is entitled to premium features, that decision should come from trusted purchase state, not from a remotely editable UI toggle.
Practical rule: Use flags for visibility, copy, flow, and gradual release. Use entitlements and server checks for billing, permissions, and anything users could exploit.

Server-side versus client-side evaluation
The first architecture choice is simple. Decide where the rule is evaluated.
For a modern indie stack, three models show up repeatedly:
| Approach | Best for | Trade-off |
|---|---|---|
| Server-side evaluation | Premium enforcement, quota limits, risky AI actions, pricing authority | More backend work. Slower to wire into UI state |
| Client-side evaluation | Onboarding variants, paywall copy, layout changes, beta surfaces | Easy to inspect on-device. Not safe for protected access |
| Hybrid model | Most SwiftUI apps using Firebase and RevenueCat | More setup upfront. Cleaner decisions later |
Statsig's guide to building feature flags describes the pattern many teams end up with. Rules live centrally, the client fetches evaluated or evaluable state at launch, and values are cached locally so the app can render quickly and keep working offline.
That hybrid model is usually the right call for indie teams. Let the app decide whether to show a new editor tab or alternate onboarding card. Let trusted systems decide whether the user can generate 100 AI summaries, access premium output, or hit a feature that has real cost behind it.
A good rule of thumb is boring and useful. If a bad actor can flip the value and get something they should not have, it is not a client-side flag.
The SwiftUI pattern that stays maintainable
The architecture usually fails in one place. Views start reaching straight into the SDK.
Once that happens, flag names leak into presentation code, previews become annoying to set up, and AI coding agents start copying string keys into random files because the path of least resistance is visible in the codebase. That is how a small flag system turns into hidden app-wide coupling.
Keep one boundary around flag evaluation:
- One service owns fetch, cache, and refresh behavior
- The app uses typed flag names, not raw strings spread across views
- Views read derived state, not vendor APIs
- Defaults are explicit, so cold start and offline behavior stay predictable
If you already have a container-based app setup, put feature flags beside analytics, purchases, and networking. A clean composition root makes this much easier, especially if you already follow a dependency injection pattern for SwiftUI apps.
For teams using AI coding agents, document the rule in the repo and enforce it in review: agents can add new typed flags to the central surface area, but they should not call the vendor SDK from views, reducers, or paywall code. That convention sounds small. It saves a surprising amount of cleanup later.
Integrating a Feature Flag SDK into SwiftUI
The SDK itself is rarely the hard part. The hard part is stopping it from leaking into the rest of the app.
If your SwiftUI views know vendor keys, flag names, refresh timing, or user attribute plumbing, you haven't integrated a flag system. You've pushed infrastructure concerns into presentation code.

Start with a typed surface area
Begin with an internal model of the flags your app cares about.
enum AppFlag: String, CaseIterable {
case newOnboarding
case aiEditor
case experimentalPaywall
case smartUpsellPrompt
}
Then define a protocol that the rest of the app depends on:
protocol FeatureFlagServing: AnyObject {
func isEnabled(_ flag: AppFlag) -> Bool
func stringValue(for flag: AppFlag) -> String?
func refresh() async
}
This buys you two things immediately. First, your views and view models stop caring which vendor you use. Second, tests can swap in a fake implementation without any remote setup.
A manager that wraps the SDK
For most indie apps, a single manager object is enough.
import Foundation
import SwiftUI
@MainActor
final class FeatureFlagManager: ObservableObject, FeatureFlagServing {
static let shared = FeatureFlagManager()
@Published private(set) var cachedFlags: [AppFlag: Bool] = [
.newOnboarding: false,
.aiEditor: false,
.experimentalPaywall: false,
.smartUpsellPrompt: false
]
private init() {}
func configure(
userID: String?,
isPro: Bool,
countryCode: String?
) {
// Initialize your SDK here.
// Pass stable user attributes used for targeting.
// Keep defaults in memory so the app behaves predictably before fetch completes.
}
func refresh() async {
// Fetch remote values from your SDK or backend.
// Update cachedFlags on success.
// Keep previous values if refresh fails.
}
func isEnabled(_ flag: AppFlag) -> Bool {
cachedFlags[flag] ?? false
}
func stringValue(for flag: AppFlag) -> String? {
nil
}
}
The key design choice is boring on purpose. The manager owns initialization, local cache, refresh, and defaults. Everyone else gets booleans or typed values.
What usually fails in small apps is overengineering. You don't need a multi-layer flag abstraction with six protocols on day one. You do need one place where evaluation enters the app.
Inject it once and keep views dumb
Pass the service through your app container or environment.
@main
struct MyApp: App {
@StateObject private var featureFlags = FeatureFlagManager.shared
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(featureFlags)
.task {
featureFlags.configure(
userID: "user_123",
isPro: false,
countryCode: Locale.current.region?.identifier
)
await featureFlags.refresh()
}
}
}
}
Then consume it in views without exposing SDK details:
struct OnboardingGateView: View {
@EnvironmentObject private var featureFlags: FeatureFlagManager
var body: some View {
Group {
if featureFlags.isEnabled(.newOnboarding) {
NewOnboardingView()
} else {
LegacyOnboardingView()
}
}
}
}
This is also the point where AI coding agents benefit from conventions. If your project rule says “all feature flags must be added to AppFlag, exposed through FeatureFlagManager, and never called directly from a view using raw strings,” generated code stays consistent.
A quick walkthrough helps if you want to compare your setup against a working pattern:
A few implementation details are worth locking in early:
- Use stable identifiers: If a user lands in a cohort, they should keep seeing the same experience when appropriate.
- Cache last known values: Don't make every app launch depend on the network.
- Separate initialization from evaluation: the UI shouldn't care whether values came from disk, memory, or the network.
- Prefer wrappers over direct SDK calls: changing vendors later is painful, but still possible if the rest of the codebase doesn't know about them.
Keep the SDK at the edge of the app. The minute it shows up in random SwiftUI views, you've lost control of the design.
Designing Your Rollout and Targeting Strategy
Friday night release. The flag is live, support is quiet, and the temptation is to turn it on for everyone and call it done. That is usually the moment small teams create avoidable cleanup work for the next week.
The rollout pattern that holds up in real apps is progressive exposure. Start with people you know, widen the audience in small steps, and promote only after the feature is stable in production. For an indie SwiftUI app, that usually means checking technical guardrails like crashes, latency, and API failures, then checking product signals like onboarding completion, trial starts, or subscription conversion before expanding access.

Rollouts should feel routine
A good rollout is boring. No surprises, no scramble, no guessing whether the dip in revenue came from the new UI or from a bad backend deploy.
My default sequence for a SwiftUI app is simple:
Internal accounts first
Turn the feature on only for your own test accounts and devices. Use this stage to catch broken navigation, missing analytics events, layout issues across device sizes, and places where the old flow still leaks through.A tiny public cohort
Move to a small percentage only after you can observe the feature end to end in production. If the feature calls an AI endpoint or a paid API, watch cost and rate-limit behavior too. Teams that store third-party credentials in config should also review API key management best practices for mobile apps before widening rollout.A controlled ramp
Increase exposure only when the first cohort looks healthy. "Seems fine" is not enough. The app, backend, and business metric tied to the feature should all look steady.General availability
Remove the release gate once the feature has proved itself and the fallback path is no longer worth maintaining.
Small blast radius beats fast regret.
Targeting that matches an indie subscription app
Small teams rarely need complicated audience rules. They do need targeting that maps cleanly to how the app makes money.
The segments I reach for most often are:
- Internal testers and TestFlight users for early validation
- Beta cohorts for rough edges and feedback
- Free versus paid users for messaging, UI treatment, and upsell tests
- Region or language groups when copy, model availability, or compliance differs
- Acquisition cohorts when onboarding or paywall behavior changes by traffic source
One rule matters here. Feature flags decide what experience to show. RevenueCat or your backend decides what the user is entitled to access. Do not let a presentation flag become your billing system.
That distinction matters even more in apps with AI features. A flag can expose a new AI workflow to paid users first, but entitlement checks still need to live in the purchase layer. If an AI coding agent touches this codebase, the convention should be explicit: add the flag to AppFlag, route evaluation through FeatureFlagManager, and keep entitlement decisions out of the view layer. That keeps generated code predictable instead of spraying raw conditions across SwiftUI screens.
A practical AI rollout example
Say you are shipping an AI photo caption tool in a SwiftUI app backed by Firebase and RevenueCat.
The product risk is not just "does it work." It is also "is the result fast enough," "does the prompt produce useful output," and "does this feature increase paid conversion or just add cost." Those are different questions, so the rollout should answer them in order.
A clean plan looks like this:
| Audience | Flag behavior | Why |
|---|---|---|
| Internal testers | Always on | Validate UX, prompt quality, and backend behavior |
| Beta users | On | Gather feedback from users who tolerate rough edges |
| Small public cohort | On for a limited percentage | Watch crashes, latency, cost, and adoption |
| Paid users only | Targeted experience | Test positioning without changing entitlement rules |
| Everyone | Full release | Remove release uncertainty after validation |
Cohort stability matters during rollouts and experiments. The same user should keep the same treatment unless you deliberately change the rule. Random assignment on every launch creates noisy analytics, confused users, and bug reports that are hard to reproduce.
Measuring Impact with Firebase and RevenueCat
Shipping behind a flag is only half the job. If you don't track exposure, you can't tell whether the feature helped or hurt.
For mobile apps, the simplest useful pattern is to log an analytics event when the app decides a user is exposed to a flagged experience. Not when the flag exists in config. Not when the app fetches values in the background. When the user hits the gated screen or branch.
Track exposure before outcomes
In Firebase Analytics, create a single event naming convention for flag exposure and stick to it.
For example:
import FirebaseAnalytics
struct FeatureExposureTracker {
static func track(flag: AppFlag, variant: String = "on") {
Analytics.logEvent("feature_exposed", parameters: [
"flag_key": flag.rawValue,
"variant": variant
])
}
}
Then call it from the point of use:
if featureFlags.isEnabled(.aiEditor) {
FeatureExposureTracker.track(flag: .aiEditor)
AIEditorView()
} else {
LegacyEditorView()
}
That event becomes the join point for everything else. You can compare downstream behavior for users who saw the new onboarding, AI editor, or experimental paywall against users who didn't.
A few rules keep this clean:
- Track once per meaningful exposure: avoid firing the same event repeatedly on every tiny UI refresh
- Log the variant or treatment name: don't leave yourself guessing later
- Keep event names stable: dashboards break when naming drifts between releases
Use RevenueCat to separate access from release
RevenueCat gives you a clean boundary between entitlement and experimentation.
That matters because a common anti-pattern is trying to use flags as payment logic. Don't. A flag can decide whether to show a new paywall layout, whether to surface a premium upsell earlier, or whether to reveal a trial explainer. RevenueCat should still remain the source for whether the user has access.
That split is especially useful when testing monetization UX:
- Paywall design variants: test different SwiftUI layouts and copy
- Upsell timing: show the prompt after creation, after onboarding, or after a free limit is reached
- Feature packaging: expose a premium AI tool to selected cohorts while RevenueCat still enforces access
When AI features enter the picture, key handling matters too. If your app talks to your own backend or a model gateway, keep secrets out of client-side configuration and follow the same discipline you'd use in API key management best practices.
What to measure without fooling yourself
Don't stop at taps.
A feature can increase engagement while hurting subscriptions. It can improve conversions while causing support pain. It can look popular because the app forced people through it.
For Firebase and RevenueCat together, I'd watch a mix of product and monetization signals:
- Exposure to conversion flow: Did users who saw the feature move deeper into onboarding or purchase intent?
- Trial or subscription behavior: Did the flagged cohort behave differently after seeing the new paywall or premium teaser?
- Retention quality: Did users come back, or did novelty create one noisy session?
- Failure-adjacent signals: Crash reports, abandoned flows, or repeated retries often explain bad outcomes before revenue dashboards do.
Analytics should answer one question: did this release improve the app for the users who actually saw it?
Production Readiness and Best Practices
The technical debt from flags doesn't come from adding them. It comes from leaving them everywhere.
Mature systems are built around local evaluation, user segmentation, and governance. Unleash recommends local evaluation so apps still work if the flagging service is unavailable, and guidance from Martin Fowler emphasizes testing both On and Off states, while mature platforms also add audit logs and cleanup rules for short-lived flags to avoid technical debt, as summarized in Unleash's feature flag best practices.

Local evaluation and offline behavior
Mobile apps need predictable behavior when connectivity is bad.
That means your app should fetch configuration, cache the last known good values, and evaluate flags locally during normal runtime. Users shouldn't lose the ability to launch the app because a remote service is slow. They also shouldn't watch the UI flip unpredictably between states on every screen transition.
A practical baseline looks like this:
- Cache on disk: persist the latest resolved values between launches
- Use defaults in code: every flag needs a safe fallback
- Refresh asynchronously: don't block first render unless the feature is critical to app startup
- Fail closed or fail safe intentionally: choose the default per flag, not by accident
Test both code paths
A lot of indie apps cut corners here.
If you only test the current visible path, rollback is theoretical. You need confidence that both the enabled and disabled states still work. That includes navigation, analytics, accessibility, loading states, and purchase flow interactions.
A small testing matrix goes a long way:
| Area | Flag off | Flag on |
|---|---|---|
| Unit tests | Old behavior remains valid | New branch returns expected state |
| SwiftUI previews | Legacy UI still renders | New UI renders with realistic data |
| UI tests | Existing path still completes | New path completes and logs exposure |
| Purchase flow | Baseline paywall works | Experimental paywall still respects entitlement |
For broader app confidence, pair this with real integration checks around networking, auth, and purchases. A practical testing workflow is easier to maintain if you already follow disciplined integration testing best practices.
Teach your AI agent your flag rules
If you use Cursor, Claude Code, Cline, or similar tools, document your conventions where the agent can read them.
Put the rules in .cursorrules, CLAUDE.md, or both:
- New flags must be added to a single enum
- Views must never use raw string keys
- Temporary release flags require an owner and cleanup note
- Entitlement checks must never rely on client-side flags
- Every new flag requires tests for both enabled and disabled states
This is one of those small habits that pays back quickly. AI agents are good at following patterns. They're bad at inventing maintainable ones unless you pin the rules down.
The fastest way to create feature flag hell is to let humans and agents add flags without a lifecycle.
A few cleanup conventions help keep the system healthy:
- Name flags for intent:
new_onboarding_releaseis better thantest2 - Prefer short-lived release flags: remove them after rollout settles
- Keep ownership visible: every flag should have a person responsible for deleting it
- Separate permanent operational switches from temporary release gates: they have different lifecycles and deserve different names
Ship Faster and Safer with Feature Flags
A good feature flag implementation doesn't make your app more complicated. It makes your release process less fragile.
For a SwiftUI app, the winning pattern is straightforward. Keep evaluation behind a centralized service. Use client-side flags for UI and rollout control, not for trust boundaries. Log exposure in Firebase so you can measure what changed. Let RevenueCat remain the authority for access. Test both code paths. Delete flags when they've done their job.
That approach fits the nature of indie development. You're shipping quickly, often with a tiny team, sometimes with AI-generated code mixed into hand-written architecture. You don't need enterprise ceremony. You need safe defaults, clean ownership, and the ability to undo a bad release decision without touching App Store Review.
The payoff is bigger than rollback safety. Flags let you release new onboarding, AI flows, and paywall ideas in smaller steps. They also force a healthier separation between product experiments and core business logic.
Start with one feature that feels risky enough to deserve protection. A new onboarding flow is perfect. An AI tool is even better. Once that first flag saves you from a bad launch, this stops feeling like extra engineering work and starts feeling like the normal way to ship.
If you want a faster starting point for this stack, Spaceport gives indie iOS teams a production-ready SwiftUI foundation with Firebase, RevenueCat, onboarding, paywalls, analytics, and AI-agent project conventions already wired in, so you can focus on shipping features safely instead of assembling the plumbing first.
