Extending Scoped Feature Flags in Swift with Async/Await — Aaron Orozco Skip to content

Extending Scoped Feature Flags in Swift with Async/Await

· 4 min read

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.