iOS SDK
Read flags in iOS and macOS apps with the Swift SDK, which evaluates them on the device and keeps working offline.
The Swift SDK fetches your environment’s client flags and evaluates them on the device, so your users’ attributes never leave the app. It starts from the last flags it saw, even offline, polls only while the app is in the foreground, and needs nothing but Apple’s own frameworks.
Install
The SDK is one Swift package for iOS 15+ and macOS 12+ (Swift 5.9+), with three libraries:
| Library | What |
|---|---|
FeatFlags |
The client. No dependencies. |
FeatFlagsOpenFeature |
An OpenFeature provider for the official Swift SDK (needs Swift 6.1). |
FeatFlagsOtel |
A hook that records evaluations on the active OpenTelemetry span (needs Swift 6.0). |
In Xcode, pick File → Add Package Dependencies, paste the URL below and pick the libraries. In Package.swift:
.package(url: "https://github.com/featflags/featflags-swift", from: "0.1.0"),
// a target's dependencies
.product(name: "FeatFlags", package: "featflags-swift"),
.product(name: "FeatFlagsOpenFeature", package: "featflags-swift"), // with the OpenFeature provider
.product(name: "FeatFlagsOtel", package: "featflags-swift"), // with the OpenTelemetry hook
Create a client
import FeatFlags
var options = FlagClient.Options(
key: "pk_…",
cdnURL: URL(string: "https://flags.featflags.io")!,
eventsURL: URL(string: "https://events.featflags.io")! // evaluation counts and experiments
)
options.context = ["key": user.id, "plan": "pro"]
let flags = try FlagClient(options)
// Optional: wait (2 s at most) for the first fetch instead of evaluating the
// last flags the device saw, the bootstrap or the fallbacks.
await flags.waitForReady()
Make one client per key, for the life of the app.
Client keys only
An app holds the environment’s client key (pk_…). It reaches the flags you mark client-visible (and the flags they need as prerequisites), with target keys hashed and private segments behind tokens your backend issues.
The client refuses a server key (sk_…): FlagClient.init throws an OptionsError. Anything shipped in an app can be extracted, and a server key reaches every flag of the environment, its rules in the clear and the secret that unlocks private segments for any user. Keep server keys on your servers, with a server SDK.
An environment’s allowed origins don’t apply to apps: a native app has no origin, so the SDK ignores the list. It only restricts browsers.
Options
FlagClient.Options |
Default | What |
|---|---|---|
key |
required | The client key, pk_…. |
cdnURL |
required | https://flags.featflags.io. |
eventsURL |
nil | https://events.featflags.io. Evaluation counts, experiment exposures and track events are off without it. |
context |
[:] (an anonymous user) |
The context to evaluate with until identify. |
membershipTokens |
[:] |
Private segment tokens for context; see Lists and private segments. |
experimentKey |
nil | The environment’s experiment key, xk_…, for anonymous users; public once in the app. See Experiments. |
experimentSeeds |
nil | Experiment seeds for context, from your backend. |
bootstrap |
nil | A Document (Document(data:), such as one bundled with the app) to evaluate before the first fetch. |
persist |
true | Keep the last flags on the device and start from them on the next launch. |
cacheDirectory |
featflags in the caches directory |
Where the last flags are kept. |
dataDirectory |
featflags in Application Support |
Where the anonymous key is kept, excluded from backup. |
readyTimeout |
2 s | How long waitForReady waits. |
pollInterval |
60 s | Time between checks for changes in the foreground, ±10%. Zero turns polling off. |
flushInterval |
60 s | Time between reports. The first waits up to one extra interval at random. |
onExposure |
nil | Called with an Exposure the first time each (context key, flag, variant) is evaluated, for your own analytics. |
hooks |
[] |
Run around every evaluation and identify; see Hooks. |
session |
URLSession.shared |
The session for every request. |
now |
the system clock | Epoch milliseconds for rollout steps and date conditions. |
log |
NSLog |
Where warnings go: failing hooks, failed reports, refused track calls and experiment keys. |
On macOS both directories go under the app’s bundle identifier (Application Support/<bundle id>/featflags), since an app that isn’t sandboxed shares them with every other.
Evaluate flags
if flags.bool("new-checkout", default: false) {
// …
}
let d = flags.stringDetail("pricing-page", default: "v1")
print("pricing-page: \(d.value) (\(d.reason.rawValue), rule \(d.ruleId ?? "-"))")
bool, string, number (a Double) and json (any value, as JSON) return the flag’s value for the client’s context, or the fallback when the flag is missing, the context is invalid or the value has another type. Their …Detail variants (boolDetail, stringDetail, numberDetail, jsonDetail) return a Detail with value, variant and the reason: .rule with ruleId, .prerequisiteFailed with prerequisite. Every getter also takes a context: to evaluate another context than the identified one.
Getters are synchronous and read flags in memory: call them on the main thread, in SwiftUI’s body, anywhere.
Contexts and identify
A Context is written as a JSON literal (contexts):
let user: Context = ["key": "u-alice", "email": "alice@acme.com", "groups": ["qa", "eng"]] // a user, the default kind
let org = Context(kind: "org", key: "acme", attributes: ["plan": "team"])
let both: Context = ["kind": "multi", "user": ["key": "u-alice"], "org": ["key": "acme", "plan": "team"]]
Attribute values are strings, numbers, booleans, or arrays of those; nil, objects and nested arrays count as missing. An invalid context (no key, a bad kind) gets the fallback with reason .error.
The client evaluates one context at a time. Call identify when the user signs in or out, or switches organisation:
await flags.identify(["kind": "multi", "user": ["key": user.id], "org": ["key": org.id]])
The context changes at once; identify returns once the context’s lists have loaded (below), so you needn’t wait for it.
A user without a key gets the device’s anonymous key, so a signed-out user stays in the same rollout buckets across launches. It’s a UUID in a file under Application Support (dataDirectory), so clearing the caches doesn’t reset it, and it’s excluded from backup, so a backup restored on another device doesn’t share it.
Lists and private segments
Targets and segment lists of up to 64 keys travel with the flags, hashed. Longer ones, up to millions of keys, are split into small files: the client fetches the identified context’s part, one request per context kind, when the context is set and when new flags need new parts. Until they arrive, list lookups don’t match and evaluations that reach them report .membershipPending; new flags that need new parts are applied once those have loaded, so a context in a list stays in it.
Private segments can only be read with a token for the user. Your backend computes it with a server SDK (membership_token(context) in Python, Ruby and Rust, MembershipToken in Go and .NET, membershipToken in Java and PHP) and hands it to the app at sign-in:
await flags.identify(["key": user.id], membershipTokens: session.featflagsTokens) // ["user": "8ecc…"]
A token applies to the identified context only. Without one, private segments can’t include the user. How lists work.
Updates, offline and the app’s lifecycle
- The client fetches the flags when it starts, then checks for changes every minute while the app is in the foreground. It never goes back to older flags, and a failed fetch keeps the last good ones.
- When the app goes to the background, it stops polling and sends its reports, asking iOS for the time to finish. When the app comes back, or the network returns, it fetches at once, at most every 5 seconds. On macOS, hiding and unhiding the app count as background and foreground.
- The last flags are kept in the caches directory, so a cold start offline evaluates the last flags the device saw;
isCachedis true until a fetch answers. iOS may purge caches, and then the next start waits for the network (or usesbootstrap). versionsays which version of your flags is in use, nil before the first.
The client tells you whenever evaluations may have changed: new flags, a new context, or the context’s lists loaded. With a callback on the main actor, for UIKit:
let subscription = flags.subscribe { [weak self] in self?.render() }
// later: subscription.cancel()
Or as an AsyncStream, for SwiftUI:
struct CheckoutButton: View {
let flags: FlagClient
@State private var newCheckout = false
var body: some View {
Button(newCheckout ? "Pay" : "Checkout") { … }
.task {
newCheckout = flags.bool("new-checkout", default: false)
for await _ in flags.changes { newCheckout = flags.bool("new-checkout", default: false) }
}
}
}
What the client sends: counts of which variant each flag served, with the reason, rule and version of your flags, never the context. What SDKs send.
Experiments
When an environment runs experiments, the client reports which variant each user saw and the events you track for the experiments’ metrics:
flags.track("checkout.completed")
flags.track("purchase", value: 49.9)
track(_:value:) is for the identified context. An event key matches [a-z0-9_.:-]{1,64} and the value is an optional number; nothing else goes with it. A call outside that is logged once and dropped, never thrown. An event no running experiment counts sends nothing, so tracking freely costs nothing.
Users are reported under IDs made with the environment’s experiment key, which featflags never holds. An app shouldn’t hold it either, since anything in an app can be extracted:
-
Signed-in users: your backend computes the user’s seeds with a server SDK (
experiment_seeds(context),ExperimentSeedsin Go and .NET,experimentSeedsin JS, Java and PHP) and hands them to the app at sign-in, with the membership tokens:let seeds = try JSONDecoder().decode(ExperimentSeeds.self, from: session.featflagsSeedsJSON) // {"fingerprint": "…", "seeds": {"user": "…"}} await flags.identify(["key": user.id], membershipTokens: session.featflagsTokens, experimentSeeds: seeds)Seeds apply to the identified context only. Seeds made with a key you’ve since rotated are ignored, with one error logged, until your backend hands over new ones.
-
Anonymous users, or an app without a backend: set
experimentKey. It’s then public: anyone can read it from the app.
Exposures and events are sent with the counts: every minute in the foreground, when the app goes to the background, on close(), and as soon as 5,000 are waiting. Each carries how long ago it happened, so events recorded offline are dated right. They’re kept in memory only, up to 50,000. track runs no hooks.
Hooks
A hook runs around every evaluation and identify: beforeEvaluation in registration order, afterEvaluation in reverse with the Detail the caller gets, on the evaluating thread; beforeIdentify and afterIdentify around identify. Conform to Hook and implement the stages you need. A stage that throws is logged and skipped. The hooks contract.
struct FlagTimer: Hook {
let name = "timer"
func beforeEvaluation(_ series: EvaluationSeries, data: SeriesData) throws -> SeriesData {
data.merging(["start": DispatchTime.now().uptimeNanoseconds]) { $1 }
}
func afterEvaluation(_ series: EvaluationSeries, data: SeriesData, detail: Detail<JSON>) throws -> SeriesData {
let start = data["start"] as? UInt64 ?? 0
print("\(series.method) \(series.flagKey): \(detail.reason.rawValue) in \((DispatchTime.now().uptimeNanoseconds - start) / 1000) µs")
return data
}
}
options.hooks = [FlagTimer()]
OpenTelemetry
OtelHook, in FeatFlagsOtel, adds a feature_flag.evaluation event with OpenTelemetry’s feature flag attributes to the active span. Evaluations outside a span record nothing.
import FeatFlagsOtel
options.hooks = [OtelHook()]
OpenFeature
FeatFlagsProvider, in FeatFlagsOpenFeature, is a provider for the OpenFeature Swift SDK (0.7). Each context OpenFeature sets identifies the client:
import FeatFlagsOpenFeature
import OpenFeature
let provider = FeatFlagsProvider(client: flags) // you own the client
await OpenFeatureAPI.shared.setProviderAndWait(
provider: provider,
initialContext: ImmutableContext(targetingKey: user.id, structure: ImmutableStructure(attributes: ["plan": .string("pro")])))
let client = OpenFeatureAPI.shared.getClient()
client.getBooleanValue(key: "new-checkout", defaultValue: false)
Without a kind attribute, an evaluation context is a user; kind: "multi" makes each structure attribute the member of the kind it’s named after. The provider emits READY once the client has flags and CONFIGURATION_CHANGED when evaluations may have changed. How reasons map to OpenFeature’s.
Shut down
flush() sends counts, exposures and events now. close() stops polling and the lifecycle observers, ends the changes streams and flushes. Both are async.