Journey before destination, tests before production

TaskLocal in Swift: The Secret Weapon for Clean Async Code

January 21, 2026

Swift’s structured concurrency brought us async/await, actors, and task groups. But there’s one feature that often flies under the radar despite being incredibly powerful: TaskLocal. If you’ve ever wrestled with passing context through layers of async functions, or wondered how Point-Free’s @Dependency library works its magic, you’re in the right place.

What is TaskLocal?

TaskLocal is Swift’s solution to propagating context through asynchronous task hierarchies. Think of it as thread-local storage reimagined for the async/await world.

At its core, TaskLocal allows you to associate values with the current task and have those values automatically propagate to child tasks. It’s declared using the @TaskLocal property wrapper:

enum MyContext {
    @TaskLocal static var requestID: String?
    @TaskLocal static var userID: Int?
}

How TaskLocal Works

Value Binding and Propagation

When you bind a value to a TaskLocal, it’s stored in the current task’s context. Any child tasks created within that scope automatically inherit this value:

await MyContext.$requestID.withValue("req-123") {
    print(MyContext.requestID) // "req-123"
    
    await Task {
        print(MyContext.requestID) // Still "req-123" - inherited!
    }.value
}

print(MyContext.requestID) // nil - outside the scope

This is fundamentally different from global variables or thread-local storage. The value is scoped to the withValue closure, and child tasks capture their parent’s values at creation time.

Under the Hood

Swift maintains a linked list of TaskLocal bindings in each task’s metadata. When you access a TaskLocal value, the runtime walks up the task hierarchy, checking each task’s bindings until it finds the value or reaches the top.

This design makes TaskLocal:

  • Scoped: Values only exist within the withValue closure
  • Inherited: Child tasks get parent values at creation time (not dynamically)
  • Copy-on-write: Mutations don’t affect other tasks
  • Efficient: Reading is relatively cheap, though not as fast as a regular variable

Five Practical iOS Use Cases

Let’s explore real-world scenarios where TaskLocal shines in iOS development.

1. Analytics and Event Tracking

Track user journey context across async operations without passing parameters through every function:

enum AnalyticsContext {
    @TaskLocal static var screenName: String?
    @TaskLocal static var featureFlag: String?
}

class ProfileViewModel {
    func loadUserProfile() async {
        await AnalyticsContext.$screenName.withValue("ProfileScreen") {
            await AnalyticsContext.$featureFlag.withValue("new_profile_ui") {
                let data = await fetchProfile()
                // Any analytics events fired deep in the call stack
                // automatically get screenName and featureFlag context
                await processData(data)
            }
        }
    }
}

Every analytics event fired anywhere in this call tree automatically includes the screen name and feature flag, without explicitly passing them through dozens of functions.

2. Network Request Context

Propagate authentication tokens and correlation IDs through your networking layer:

enum NetworkContext {
    @TaskLocal static var authToken: String?
    @TaskLocal static var correlationID: UUID?
}

class APIClient {
    func fetchUserData() async throws -> User {
        await NetworkContext.$correlationID.withValue(UUID()) {
            // All network calls in this tree automatically include
            // the correlation ID in headers for distributed tracing
            let profile = await get("/user/profile")
            let settings = await get("/user/settings")
            return User(profile: profile, settings: settings)
        }
    }
    
    private func get(_ path: String) async -> Data {
        var request = URLRequest(url: URL(string: path)!)
        
        if let token = NetworkContext.authToken {
            request.setValue("Bearer \(token)", 
                           forHTTPHeaderField: "Authorization")
        }
        
        if let correlationID = NetworkContext.correlationID {
            request.setValue(correlationID.uuidString, 
                           forHTTPHeaderField: "X-Correlation-ID")
        }
        
        // Perform request...
    }
}

This pattern is invaluable for distributed tracing and debugging production issues. Every request in the chain shares the same correlation ID without manual threading.

3. Database Transaction Scoping

Maintain transaction context through Core Data or other database operations:

enum DatabaseContext {
    @TaskLocal static var transaction: DatabaseTransaction?
    @TaskLocal static var isReadOnly: Bool = false
}

class UserRepository {
    func updateUserWithRelations(user: User) async throws {
        let transaction = database.beginTransaction()
        
        try await DatabaseContext.$transaction.withValue(transaction) {
            await DatabaseContext.$isReadOnly.withValue(false) {
                try await saveUser(user)
                try await saveUserSettings(user.settings)
                try await saveUserPreferences(user.preferences)
                // All operations use the same transaction automatically
                try await transaction.commit()
            }
        }
    }
}

Deep in your data access layer, you can check DatabaseContext.transaction to ensure you’re using the correct transaction scope, preventing subtle bugs from mixing transactional and non-transactional operations.

4. Logging Context

Enrich logs with contextual information throughout the call stack:

enum LoggingContext {
    @TaskLocal static var userID: String?
    @TaskLocal static var sessionID: String?
    @TaskLocal static var operation: String?
}

class CheckoutFlow {
    func processCheckout(cart: Cart) async {
        await LoggingContext.$userID.withValue(cart.userID) {
            await LoggingContext.$sessionID.withValue(UUID().uuidString) {
                await LoggingContext.$operation.withValue("checkout") {
                    logger.info("Starting checkout") 
                    // Logs: [userID=123, sessionID=abc, operation=checkout] Starting checkout
                    
                    await validateCart(cart)
                    await processPayment(cart)
                    await createOrder(cart)
                    // All nested operations automatically include context
                }
            }
        }
    }
}

Your logging infrastructure can automatically include TaskLocal values in every log entry, making it trivial to trace a user’s journey through your system.

5. Feature Flags and A/B Testing

Propagate experiment configurations through view models and services:

enum ExperimentContext {
    @TaskLocal static var variant: String?
    @TaskLocal static var experimentID: String?
}

class ProductListViewModel: ObservableObject {
    func loadProducts() async {
        let experiment = experimentService.getActiveExperiment()
        
        await ExperimentContext.$experimentID.withValue(experiment.id) {
            await ExperimentContext.$variant.withValue(experiment.variant) {
                let products = await fetchProducts()
                
                // Sorting algorithm can check variant without passing params
                let sorted = await sortProducts(products)
                
                // Analytics automatically tagged with experiment context
                trackEvent("products_loaded", count: sorted.count)
            }
        }
    }
    
    private func sortProducts(_ products: [Product]) -> [Product] {
        switch ExperimentContext.variant {
        case "personalized": return personalizedSort(products)
        case "popular": return popularitySort(products)
        default: return products
        }
    }
}

The beauty here is that your sorting logic, analytics, and any other code can access the experiment context without it being explicitly threaded through every function signature.

Why Point-Free Built @Dependency on TaskLocal

Point-Free’s @Dependency library is one of the most elegant dependency injection systems in Swift, and it’s built entirely on TaskLocal. Let’s explore why this architectural choice is so brilliant.

1. Automatic Propagation Through Async Boundaries

Modern iOS apps are full of async/await. Dependencies need to flow through your entire call graph:

@Dependency(\.apiClient) var apiClient
@Dependency(\.database) var database

func loadUserData() async {
    // These dependencies automatically propagate to child tasks
    async let profile = fetchProfile()
    async let orders = fetchOrders()
    
    await (profile, orders)
    // fetchProfile and fetchOrders can access the same dependencies
    // without explicitly passing them
}

Without TaskLocal, you’d need to manually thread dependencies through every async boundary or resort to global singletons. TaskLocal gives you the best of both worlds: clean code with proper scoping.

2. Test Overrides That Respect Concurrency

The killer feature for testing - you can override dependencies in a scope and they propagate correctly through concurrent operations:

func testUserLogin() async {
    await withDependencies {
        $0.apiClient = .mock
        $0.uuid = .incrementing
    } operation: {
        // All async tasks created here see the mock dependencies
        let viewModel = LoginViewModel()
        await viewModel.login(email: "test@example.com")
        
        // Even though login() spawns multiple child tasks,
        // they all see the mocked apiClient
    }
}

This is much harder with traditional dependency injection. TaskLocal ensures test isolation even when code spawns concurrent tasks internally. Each test gets its own dependency context that doesn’t leak into other tests.

3. Avoiding Global Mutable State

Traditional DI often relies on global singletons or environment objects. TaskLocal provides a better alternative:

// NOT this (global, mutable, threading issues):
class DependencyContainer {
    static var shared = DependencyContainer()
    var apiClient: APIClient
}

// But THIS (scoped, immutable, thread-safe):
enum DependencyValues {
    @TaskLocal static var current = DependencyValues()
}

Each task tree gets its own dependency context without shared mutable state. This eliminates entire classes of bugs related to concurrent access.

4. SwiftUI Preview Support

Previews can override dependencies cleanly:

#Preview {
    withDependencies {
        $0.apiClient = .previewData
        $0.currentDate = Date(timeIntervalSince1970: 0)
    } operation: {
        UserProfileView()
    }
}

The preview’s async operations all inherit the preview dependencies. This is critical for Xcode previews that need isolated environments. Without TaskLocal, you’d be fighting with global state and risking contamination between previews.

5. Avoiding “Dependency Hell” in Init Methods

Without TaskLocal, you’d write this monstrosity:

class ViewModel {
    init(
        apiClient: APIClient,
        database: Database,
        analytics: Analytics,
        logger: Logger,
        uuid: UUIDGenerator,
        dateGenerator: DateGenerator,
        // ... and 10 more dependencies
    ) {
        // Store all these...
        // Pass them to helper methods...
        // Cry a little...
    }
}

With TaskLocal-backed dependencies:

class ViewModel {
    @Dependency(\.apiClient) var apiClient
    @Dependency(\.database) var database
    // Dependencies available everywhere, no init pollution
    
    init() {}
}

Your initializers stay clean, and you don’t need to pass dependencies through layers of helper methods.

6. Hierarchical Overrides

You can nest dependency scopes, with inner scopes overriding outer ones:

await withDependencies {
    $0.apiClient = .production
} operation: {
    // Uses production apiClient
    
    await withDependencies {
        $0.apiClient = .staging  // Overrides production
    } operation: {
        // Uses staging apiClient
    }
    
    // Back to production here
}

TaskLocal’s scoping semantics make this natural and safe. You can have different dependency configurations for different parts of your application, all running concurrently without interference.

7. Live vs Test vs Preview Contexts

Point-Free can provide different dependency configurations per context:

extension DependencyValues {
    static let live = /* production dependencies */
    static let test = /* test doubles */
    static let preview = /* preview data */
}

TaskLocal ensures these contexts don’t leak between test runs or affect each other. Each context is isolated and safe.

The Alternative (and Why It’s Worse)

What would Point-Free have done without TaskLocal? The alternatives all have significant drawbacks:

Thread-Local Storage

Breaks completely with async/await. Your code can hop between threads, and thread-local values don’t propagate with the task.

Global Singletons

Hard to test, race conditions everywhere, no isolation between tests or different parts of your app running concurrently.

Manual Parameter Passing

Tedious, error-prone, and makes refactoring a nightmare. Every function signature becomes polluted with dependencies.

Environment Objects

SwiftUI-only, doesn’t help in view models, repositories, or service layers where most of your business logic lives.

TaskLocal elegantly solves all these problems by giving developers scoped, inherited, concurrency-safe storage that works everywhere in Swift.

Best Practices

When using TaskLocal in your own code:

  1. Use for context, not data: TaskLocal is perfect for request IDs, user sessions, and configuration. It’s not a replacement for passing actual data through function parameters.

  2. Keep values lightweight: TaskLocal values are copied when tasks are created. Heavy objects can impact performance.

  3. Document your TaskLocals: Make it clear what each TaskLocal is for and when it should be set.

  4. Provide sensible defaults: Use optional types or provide default values so code doesn’t crash when TaskLocal isn’t set.

  5. Test with different values: Ensure your code behaves correctly when TaskLocal values change between parent and child tasks.

Conclusion

TaskLocal is one of those features that doesn’t get enough attention but solves real problems elegantly. It enables patterns that would be difficult or impossible otherwise, particularly in the world of structured concurrency.

Point-Free’s decision to build their @Dependency library on TaskLocal shows the power of the primitive. It’s not just about avoiding global state - it’s about creating a dependency injection system that truly respects Swift’s concurrency model.

If you’re building iOS apps with async/await, understanding TaskLocal will level up your architecture. Whether you use Point-Free’s library or roll your own context propagation system, TaskLocal is the tool you need.

Next time you find yourself threading parameters through five layers of async functions, remember: TaskLocal might be exactly what you need.


Serg Dort

Written by Serg Dort. Say one thing for him: he ships code.