Extending Scoped Feature Flags in Swift with Async/Await
In the previous post we built scoped feature flags using @TaskLocal. If you missed it, you can read it here
In this post we’ll extend the withExperiment helper to support async/await, and look at how @TaskLocal values propagate through Swift’s structured concurrency model.
async withExperiment()
We make the operation closure async, propagate throws, and await the result inside the task-local override scope.
@discardableResult
func withExperiment<R>(
_ featureFlag: FeatureFlag,
enabled: Bool,
operation: () async throws -> R
) async rethrows -> R {
let override = EphemeralFeatureFlagProvider(
base: resolvedProvider,
overrides: [featureFlag: enabled])
return try await $overrideProvider.withValue(override) {
try await operation()
}
}
This allows us to override the feature flag for async contexts where the system under test needs to be awaited. It looks simple enough, and usage is quite clean:
await withExperiment(.featureA, enabled: true) {
let sut = FeatureGatedViewModel()
await sut.load()
assert(sut.isFeatureAEnabled)
}
@TaskLocal Propagation
@TaskLocal values flow down the structured concurrency tree. Child tasks created with async let, withTaskGroup, Task {}, or regular async calls all inherit the override because they’re part of the same task tree.
await withExperiment(.featureA, enabled: true) {
// direct async call inherits override
let sutA = await doAsyncWork()
// async let inherits override
async let sutB = FeatureGatedViewModel()
let resolvedB = await sutB
assert(resolvedB.isFeatureAEnabled) // true
// task group inherits override
await withTaskGroup(of: Void.self) { group in
group.addTask {
let sutC = FeatureGatedViewModel()
assert(sutC.isFeatureAEnabled) // true
}
}
// unstructured (non-detached) Task inherits override
Task {
let sutD = FeatureGatedViewModel()
assert(sutD.isFeatureAEnabled) // true
}
}
Any @Experiment initialized within these child tasks snapshots the overridden provider, just like in the synchronous case.
Task.detached Caveat
Task.detached does not inherit task-local values.
Detached tasks start outside the structured task tree. They do not inherit actor context, task priority, or task-local state. As a result, any @Experiment created inside a detached task will resolve using the defaultProvider.
await withExperiment(.featureA, enabled: true) {
let sut = FeatureGatedViewModel()
assert(sut.isFeatureAEnabled) // true
Task.detached {
let detachedSut = FeatureGatedViewModel()
assert(detachedSut.isFeatureAEnabled) // false — override is gone
}
}
The snippet above shows the detached task inside of a test, which is uncommon in practice.
The more realistic case is when the system under test spawns a detached task internally — for example, a fire-and-forget analytics call or background request.
If that detached task initializes @Experiment, it will resolve against the defaultProvider rather than the override.
func detachedExperiment() {
Task.detached {
// @TaskLocal override will not apply when
// initialized in detached task scope.
@Experiment(.isFeatureAEnabled) var isFeatureAEnabled
if isFeatureAEnabled {
// do work
}
}
}
If you need flag state inside a detached task, resolve it before crossing the boundary.
func detachedExperiment() {
@Experiment(.isFeatureAEnabled) var isFeatureAEnabled
Task.detached {
if isFeatureAEnabled {
// do work
}
}
}
The Mental Model
@TaskLocal values propagate through any task that inherits context from its parent. That includes async let, Task {}, withTaskGroup, and calls across actor boundaries. The override travels with the task, not the thread or the actor.
The only exception is Task.detached, which explicitly opts out of all inherited context — task-local values, priority, and actor isolation.
The rule: if Swift considers it structured (or unstructured-but-not-detached), withExperiment works. If it’s detached, resolve the flag before crossing the boundary.