Server-driven gating for indie iOS apps
How indie iOS apps can use server-driven configuration and gating to control rollout, recovery, and feature availability without waiting for App Review.
On this page
A small team ships version 3.2 on a Thursday evening. On Friday morning, Sentry shows a crash in the new import flow on one device family, and the paywall is quoting a price for an offer that expired at midnight. The fix is ten lines. Getting it to users takes an expedited review request, a weekend of waiting, and then however long it takes people to update.
Server-driven gating is the part of the app that lets you act before that review finishes. For an indie app it does not need a feature-management vendor. It needs one small config document, a client that treats it carefully, and a short list of decisions you are allowed to make remotely.
I covered flag categories, bucketing, and cleanup in Feature flags: safe rollouts without shipping fear. This post is about the narrower job: the remote controls a one- or two-person studio needs for rollout and recovery, and how to build them so they work on the bad days.
1. Decide what the server is allowed to control
Start with a short list. For most indie apps the useful remote controls are:
- turning one feature off when it crashes or misbehaves
- rolling a new feature out to a percentage of users
- requiring a minimum app version when an old build is broken or talks to a retired API
- changing which paywall offer or product identifiers are shown
- showing a short service notice when a backend dependency is down
- switching an AI feature to a cheaper model or a fallback route
Everything else stays in the binary. Layout, navigation, copy for core screens, and business rules that affect what users pay are poor candidates. A config document that can rearrange the app is a second codebase with no compiler, no tests, and no review.
The list also keeps you honest with App Review. Remote configuration that turns finished, reviewed features on and off is normal. Downloading code that changes what the app does, or hiding functionality from review and enabling it afterwards, is how apps get rejected or removed. If a reviewer would see something materially different from what users see after you flip a value, reconsider the gate.
2. Keep the config document small and versioned
One JSON file is enough for a long time. Serve it from your backend or a CDN, and give it an explicit schema version:
{
"schema": 2,
"generatedAt": "2026-11-01T08:00:00Z",
"minimumVersion": { "build": 412, "message": "This version can no longer sync. Please update." },
"features": {
"smartImport": { "enabled": true, "rollout": 0.25 },
"aiSuggestions": { "enabled": true, "route": "standard" }
},
"paywall": { "offering": "default" },
"notice": null
}A few rules make this format survive several app versions:
- add fields, never change the meaning of an existing one
- bump
schemaonly for a breaking change, and keep serving the old shape to old clients until they are gone - give every field a client-side default so a missing key is harmless
- keep the file small enough that fetching it on launch costs nothing noticeable
Old builds stay installed for years. Whatever you publish has to make sense to the oldest client still reading it, which is the main reason to keep the document boring.
3. Make the client fail toward the shipped behavior
The client should never block launch on the config fetch. Launch with the last good snapshot, or with bundled defaults on first run, and refresh in the background.
import Foundation
struct RemoteConfig: Codable, Sendable {
var schema: Int
var minimumBuild: Int?
var features: [String: FeatureGate]
var paywallOffering: String?
static let bundled = RemoteConfig(
schema: 2,
minimumBuild: nil,
features: [:],
paywallOffering: "default"
)
}
struct FeatureGate: Codable, Sendable {
var enabled: Bool
var rollout: Double?
}
actor ConfigStore {
private(set) var current: RemoteConfig
private let cacheURL: URL
init(cacheURL: URL) {
self.cacheURL = cacheURL
if let data = try? Data(contentsOf: cacheURL),
let cached = try? JSONDecoder().decode(RemoteConfig.self, from: data) {
current = cached
} else {
current = .bundled
}
}
func refresh(from url: URL) async {
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return }
let decoded = try JSONDecoder().decode(RemoteConfig.self, from: data)
guard decoded.schema <= 2 else { return }
current = decoded
try? data.write(to: cacheURL, options: .atomic)
} catch {
// Keep the last good snapshot.
}
}
}The important behavior is in the failure paths. A decode error, a 500, an unknown schema, or no network all leave the app on the last config it understood. A bad deploy of the config file should never be worse than having no config at all.
Pick the safe value for each gate on purpose. For a new feature, the bundled default is usually off. For a kill switch on an existing feature, the default is "feature works", and the remote value turns it off. Getting that backwards means an offline user loses a feature they paid for.
4. Evaluate gates once per flow
Read the config when a flow starts and keep that decision until the flow ends. If the snapshot refreshes while a user is halfway through an import, the import should finish under the rules it started with.
For percentage rollouts, bucket from a stable identifier, such as an install ID stored in the Keychain or your account ID, hashed together with the feature name. Do not use hashValue for this; Swift randomizes it per launch. A user who sees a feature on Monday and loses it on Tuesday because of a hash change will file a bug, and they will be right.
Keep the gate checks at the entry points: the button that starts the flow, the coordinator that owns it, the service that calls the backend. A gate checked in six views becomes six chances to disagree.
5. Treat minimum version as a last resort
A forced update screen is the strongest control you have, and the most expensive for users. Use it when an old build is actually broken: it corrupts data, it calls an endpoint you have to retire, or it has a security problem. Do not use it to push adoption of a new feature.
Some rules that keep it from causing an incident of its own:
- compare build numbers, not marketing version strings
- show the message only after the new build is live in every App Store region you ship to
- link straight to the App Store page and let the user leave the screen if the app can still run in a reduced mode
- prefer a soft prompt for a week before a hard requirement, unless the old build is causing damage
Test the hard gate on a real device before you need it. A blocking screen with a broken App Store link is a support problem you created yourself.
6. Gate the paywall and AI routes from the server
Two areas move faster than app releases for most indie apps: pricing and AI providers.
For the paywall, keep product identifiers and offer logic in your billing service and let the config choose which offering the app shows. RevenueCat offerings work this way already. The app should still render a valid paywall with the bundled default offering if the config or the billing service is unreachable. A paywall that fails closed is a direct revenue loss, and it is also one of the most common causes of one-star reviews that say "the app doesn't work".
For AI features, let the config pick a route: the standard model, a cheaper fallback, an on-device path, or off. That gives you a lever when a provider has an outage or your spend spikes. A route change still has to respect what the user agreed to. If the privacy disclosure names one provider, switching to another needs an app update with new disclosure and consent, so that option should not exist in the config.
7. Treat config changes like releases
The config file has the same blast radius as a release, so treat changes to it like one:
- keep it in a repository and change it through a commit, with a short reason in the message
- validate it in CI against the same
Codabletypes the app uses, so a typo fails before it ships - serve it over HTTPS from a host you control, with caching short enough that a fix lands in minutes
- keep a one-command rollback to the previous version
Signing the document is worth it if the config can disable payments or force updates and you serve it from a third-party CDN. For a file on your own backend over HTTPS, a validated schema and code review are usually enough.
Write a short runbook next to it. During an incident you want three lines that say which key to change, what users will see, and how to undo it. You do not want to reconstruct that from the Swift source at 23:00 on a Sunday.
8. Log which config a session used
When a user reports a bug, you need to know which gates they were under. Attach the config's generatedAt value and the active gate decisions to crash reports and support diagnostics. In Sentry this fits in a context or a few tags.
An internal debug screen that shows the current snapshot, its age, and any local overrides saves more time than it costs to build. Most "I can't reproduce it" conversations end once someone looks at that screen.
9. Keep the gate count low
Every gate is a branch you have to test in both states. An indie app with two active rollouts and three kill switches is manageable. One with twenty is shipping combinations nobody has run.
Delete a rollout gate once the feature is at 100% and stable for a release cycle. Keep kill switches only for features that have failed before or depend on something outside your control, such as a model provider or a sync backend. Review the config file before each release and remove anything that no longer matches code in the current build.
For a small studio, the right amount of server-driven control is the amount you can test before every release and explain from memory during an incident. Usually that is a single file with fewer than ten keys, and it is enough to get through a bad Friday without waiting on App Review.