Feature Flags and Gradual Rollouts in SwiftUI
You've shipped the app. Users are signed up, landmarks are syncing, push notifications are flowing. Now you want to add a new feature - say, the ability to reserve visits to landmarks. But you don't want to ship it to every user at once. Maybe it's not fully tested at scale. Maybe you want to see how a small group reacts before committing.
This is what feature flags solve. Instead of deploying code and hoping for the best, you deploy the code behind a flag and turn it on for 10% of users. If it works, bump to 50%, then 100%. If it breaks, flip the flag off without deploying anything.
This post builds the client-side pattern: a provider-agnostic feature flag layer with property wrappers that make SwiftUI views automatically react to flag changes. You bring the provider, we bring the architecture.
The companion repo has the complete working code: View Code
The Provider Protocol
The whole point is that your app shouldn't know or care where flags come from. Define a protocol that any provider can conform to:
protocol FeatureFlagProvider: Sendable {
func fetchFlags() async throws -> [String: Bool]
func observeFlag(_ name: String, handler: @Sendable @escaping (Bool) -> Void)
func stopObserving(_ name: String)
}
Three responsibilities: fetch the current state, observe real-time changes, and clean up. Some providers (like a simple JSON endpoint) won't support real-time observation. That's fine - the handler just never fires and the app falls back to the fetched value.
The Feature Flag Store
An @Observable store manages all flag state. It fetches on launch, caches in memory, and applies overrides for testing:
@Observable
final class FeatureFlagStore {
private(set) var flags: [String: Bool] = [:]
private var overrides: [String: Bool] = [:]
private(set) var isLoaded = false
private let provider: FeatureFlagProvider
init(provider: FeatureFlagProvider) {
self.provider = provider
}
func load() async {
do {
flags = try await provider.fetchFlags()
} catch {
// Keep whatever we had cached
}
isLoaded = true
}
func isEnabled(_ flag: String) -> Bool {
overrides[flag] ?? flags[flag] ?? false
}
func observe(_ flag: String) {
provider.observeFlag(flag) { [weak self] enabled in
Task { @MainActor in
self?.flags[flag] = enabled
}
}
}
func setOverride(_ flag: String, enabled: Bool) {
overrides[flag] = enabled
}
func clearOverrides() {
overrides.removeAll()
}
}
When a provider pushes a real-time update, the store's flags dictionary changes, which triggers @Observable to notify any SwiftUI view that's reading that flag. No Combine, no manual refresh.
The @FeatureFlag Property Wrapper
Checking flags.isEnabled("reservations") everywhere works, but a property wrapper is cleaner and makes the dependency on a specific flag explicit:
@propertyWrapper
struct FeatureFlag: DynamicProperty {
@Environment(FeatureFlagStore.self) private var store
let name: String
let defaultValue: Bool
init(_ name: String, default defaultValue: Bool = false) {
self.name = name
self.defaultValue = defaultValue
}
var wrappedValue: Bool {
store.overrides[name] ?? store.flags[name] ?? defaultValue
}
}
By conforming to DynamicProperty, the property wrapper participates in SwiftUI's update cycle. When the store changes, views using @FeatureFlag re-evaluate automatically.
Now views read like this:
struct LandmarkDetailView: View {
let landmark: Landmark
@FeatureFlag("reservations") private var reservationsEnabled
@FeatureFlag("social_sharing") private var socialSharingEnabled
var body: some View {
ScrollView {
LandmarkHeader(landmark: landmark)
LandmarkDescription(landmark: landmark)
if reservationsEnabled {
ReserveVisitButton(landmark: landmark)
}
if socialSharingEnabled {
ShareButton(landmark: landmark)
}
}
}
}
The intent is immediately clear. You can grep the codebase for @FeatureFlag("reservations") to find every place a flag is used, which makes cleanup trivial when you eventually remove one.
Wiring It Into the App
Register the store after authentication, just like every other service in the series:
struct ContentView: View {
@State private var authStore: AuthStore
@State private var flagStore: FeatureFlagStore
init(authService: AuthService, flagProvider: FeatureFlagProvider) {
_authStore = State(initialValue: AuthStore(service: authService))
_flagStore = State(initialValue: FeatureFlagStore(provider: flagProvider))
}
var body: some View {
if authStore.isAuthenticated {
LandmarksNavigationView()
.environment(flagStore)
.task {
await flagStore.load()
}
} else {
LoginView()
}
}
}
The provider is injected at the top level. Swap it for tests, swap it for a different vendor, swap it for a JSON file during development. The views don't change.
Real-Time Flag Updates
For providers that support it, start observing specific flags after load:
.task {
await flagStore.load()
flagStore.observe("reservations")
flagStore.observe("social_sharing")
}
When the provider calls the handler (for example, because someone toggled a flag in a dashboard), the store updates, and every view using @FeatureFlag("reservations") re-renders. The user sees the feature appear or disappear without restarting the app.
For providers that don't support real-time updates, observeFlag is a no-op. The app still works - it just uses the value from the last fetch.
A Simple JSON Provider
For teams that don't need a full feature flag service, a JSON file on a CDN or a simple API endpoint works:
struct JSONFlagProvider: FeatureFlagProvider {
let url: URL
func fetchFlags() async throws -> [String: Bool] {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([String: Bool].self, from: data)
}
func observeFlag(_ name: String, handler: @Sendable @escaping (Bool) -> Void) {
// No real-time support - flags update on next fetch
}
func stopObserving(_ name: String) {}
}
Host a flags.json file and you're done. Update the JSON, and the next time the app launches or re-fetches, the flags change. No SDK, no dependency, no account to manage.
Using the Closure-Based Service Pattern
If you prefer the closure-based approach from earlier in the series over a protocol, the provider can be a struct with closures instead:
struct FeatureFlagService: Sendable {
var fetchFlags: @Sendable () async throws -> [String: Bool]
var observeFlag: @Sendable (_ name: String, _ handler: @Sendable @escaping (Bool) -> Void) -> Void
var stopObserving: @Sendable (_ name: String) -> Void
}
extension FeatureFlagService {
static func json(url: URL) -> FeatureFlagService {
FeatureFlagService(
fetchFlags: {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([String: Bool].self, from: data)
},
observeFlag: { _, _ in },
stopObserving: { _ in }
)
}
static let unimplemented = FeatureFlagService(
fetchFlags: { fatalError("FeatureFlagService.fetchFlags not implemented") },
observeFlag: { _, _ in fatalError() },
stopObserving: { _ in fatalError() }
)
}
Either approach works. Use whichever matches the rest of your codebase.
Debug Overrides
During development, you want to force flags on or off without touching the provider. The store already supports overrides. Add a debug screen to toggle them:
#if DEBUG
struct FeatureFlagDebugView: View {
@Environment(FeatureFlagStore.self) private var flagStore
var body: some View {
List {
Section("Server Flags") {
ForEach(
flagStore.flags.sorted(by: { $0.key < $1.key }),
id: \.key
) { name, enabled in
Toggle(name, isOn: Binding(
get: { flagStore.isEnabled(name) },
set: { flagStore.setOverride(name, enabled: $0) }
))
}
}
Section {
Button("Clear All Overrides") {
flagStore.clearOverrides()
}
}
}
.navigationTitle("Feature Flags")
}
}
#endif
This is useful regardless of which provider you use. QA can toggle flags without needing access to the provider's dashboard.
Testing
Because the provider is a protocol, testing is just a matter of injecting known values:
struct MockFlagProvider: FeatureFlagProvider {
let flags: [String: Bool]
func fetchFlags() async throws -> [String: Bool] { flags }
func observeFlag(_ name: String, handler: @Sendable @escaping (Bool) -> Void) {}
func stopObserving(_ name: String) {}
}
@Test func reservationsHiddenWhenFlagDisabled() async {
let provider = MockFlagProvider(flags: ["reservations": false])
let store = FeatureFlagStore(provider: provider)
await store.load()
#expect(store.isEnabled("reservations") == false)
}
@Test func overrideTakesPrecedence() async {
let provider = MockFlagProvider(flags: ["reservations": false])
let store = FeatureFlagStore(provider: provider)
await store.load()
#expect(store.isEnabled("reservations") == false)
store.setOverride("reservations", enabled: true)
#expect(store.isEnabled("reservations") == true)
store.clearOverrides()
#expect(store.isEnabled("reservations") == false)
}
@Test func realTimeUpdateReflectsInStore() async {
var handler: ((Bool) -> Void)?
let provider = FeatureFlagService(
fetchFlags: { ["reservations": false] },
observeFlag: { name, h in if name == "reservations" { handler = h } },
stopObserving: { _ in }
)
let store = FeatureFlagStore(provider: provider)
await store.load()
store.observe("reservations")
#expect(store.isEnabled("reservations") == false)
// Simulate real-time update from provider
handler?(true)
// Store should reflect the change
#expect(store.isEnabled("reservations") == true)
}
Choosing a Provider
The pattern above works with any backend. Here's a quick lay of the land:
A JSON endpoint is the simplest option. Host a file, fetch it on launch. No SDK, no real-time updates, no cost. Good enough for small teams and simple use cases.
LaunchDarkly is the most full-featured option: targeting rules, segments, experimentation, audit logs. It's powerful but the pricing reflects that. For a solo developer or small team, it can be hard to justify the cost for what's essentially a boolean lookup.
Firebase Remote Config was an early option in this space, but it hasn't kept pace. The targeting capabilities are limited, the SDK is heavy, and it's tightly coupled to the rest of the Firebase ecosystem. If you're already deep in Firebase it's convenient, but it's not where innovation is happening.
Grantiva - full disclosure, this is a product I built. I created it specifically because I wanted something simpler than LaunchDarkly without the baggage of Firebase. You absolutely don't need it - the provider-agnostic pattern in this post works with anything, including a JSON file on a CDN. But if you want a managed service with a free tier that plugs directly into the FeatureFlagProvider protocol above, I wrote a step-by-step integration guide that gets you from zero to working feature flags in about 15 minutes.
The point of building a provider-agnostic layer is that this choice isn't permanent. Start with a JSON file, migrate to a managed service when you need targeting and real-time updates. The views never change.
Series Wrap-Up
This is the final post in the "Making It Real" series. Over eight posts, we took the Landmarks app from "it works in development" to something you could genuinely ship:
- Authentication - Users have accounts, routes are protected
- Push Notifications - Real-time updates with deep linking
- Offline Storage - Works without a network connection
- Background Sync - Changes propagate automatically with conflict handling
- Observability - Structured logging and health checks in production
- Performance - Profiled and optimized with real measurements
- Release Pipeline - Automated builds, merge-backs, and prereleases
- Feature Flags - Gradual rollouts without redeploying
Every pattern in this series builds on the foundation from Series 1: closure-based dependency injection, @Observable stores, @Entry environment values, and the Navigator pattern. Those simple building blocks scale from a tutorial app to a production system without changing the architecture.
The full code for every post in both series is available on GitHub.