Swift Task Local Storage with @TaskLocal



This content originally appeared on DEV Community and was authored by ArshTechPro

What is a Task in Swift?

Think of a Task as a box that contains some work your app needs to do. In Swift’s async/await world, when you write async functions, you’re creating these boxes of work that can run at the same time.

func parentFunction() async {
    // This is the main task (parent)
    async let child1 = childFunction1()  // Creates a child task
    async let child2 = childFunction2()  // Creates another child task

    await [child1, child2]  // Wait for both children to finish
}

The parent task creates child tasks, and they all form a family tree. The parent waits for its children to finish before it can finish.

What is @TaskLocal?

@TaskLocal is like a magic backpack that gets passed down from parent tasks to their children automatically. You put something in the backpack once, and all the child tasks can see what’s inside without you having to manually hand it to each one.

Simple rules:

  • Must be declared as static
  • Automatically shared with child tasks
  • Only exists while the task is running

Example 1: Department Processing System

Let’s create a completely different scenario. Imagine you’re building a system where different departments process requests simultaneously:

enum Department {
    @TaskLocal static var currentDepartment: String = "Unassigned"
}

func processRequests() async {
    // Create tasks for different departments
    let hrTask = Task {
        await Department.$currentDepartment.withValue("HR") {
            print(" \(Department.currentDepartment) department started processing")
            try? await Task.sleep(nanoseconds: 500_000) // Simulate work
            print(" \(Department.currentDepartment) department finished processing")
        }
    }

    let itTask = Task {
        await Department.$currentDepartment.withValue("IT") {
            print(" \(Department.currentDepartment) department started processing")
            try? await Task.sleep(nanoseconds: 700_000) // Simulate work
            print(" \(Department.currentDepartment) department finished processing")
        }
    }

    // Check what department we're in outside any task scope
    print(" Main office department: \(Department.currentDepartment)")

    // Wait for all departments to finish
    await hrTask.value
    await itTask.value
}

When you run this code, you might see output like:

 IT department started processing
 HR department started processing
 Main office department: Unassigned
 HR department finished processing
 IT department finished processing

What’s happening here?

  1. Two departments (HR and IT) process requests concurrently
  2. Each task knows which department it belongs to
  3. The main office (outside any task) shows “Unassigned”
  4. Each department maintains its identity throughout the entire process
  5. The processing happens in parallel, so the order may vary

Example 2: Multiple Values with Nested Tasks

Now let’s see how one @TaskLocal can store multiple values and work with child tasks:

struct AppContext {
    let userID: String
    let sessionToken: String
    let requestID: String
}

enum AppSession {
    @TaskLocal static var context: AppContext?
}

func handleAPIRequest() async {
    let appContext = AppContext(
        userID: "alice123",
        sessionToken: "token_abc", 
        requestID: "req_789"
    )

    await AppSession.$context.withValue(appContext) {
        await processRequest()
    }
}

func processRequest() async {
    logRequest()

    // Create child tasks - they inherit the context automatically
    async let dataFetch = fetchUserData()
    async let validation = validateRequest()

    await [dataFetch, validation]

    logCompletion()
}

func logRequest() {
    guard let ctx = AppSession.context else { return }
    print("Starting request \(ctx.requestID) for user \(ctx.userID)")
}

func fetchUserData() async {
    guard let ctx = AppSession.context else { return }
    print("Fetching data for user: \(ctx.userID) with token: \(ctx.sessionToken)")
}

func validateRequest() async {
    guard let ctx = AppSession.context else { return }
    print("Validating request: \(ctx.requestID)")
}

func logCompletion() {
    guard let ctx = AppSession.context else { return }
    print("Completed request \(ctx.requestID) for user \(ctx.userID)")
}

Let’s run this:

await handleAPIRequest()

Output:

Starting request req_789 for user alice123
Fetching data for user: alice123 with token: token_abc
Validating request: req_789
Completed request req_789 for user alice123

What’s happening here?

  1. We set ONE context containing multiple values
  2. Both the main function AND child tasks (async let) can access all the values
  3. No parameters needed – everything flows automatically through the task hierarchy!

Why is This Amazing?

Without @TaskLocal, you’d have to do this:

func processAPIRequest(user: UserContext) async {
    await checkPermissions(user: user)
    await fetchUserData(user: user)  
    await logActivity(user: user)
}

func checkPermissions(user: UserContext) async { ... }
func fetchUserData(user: UserContext) async { ... }
func logActivity(user: UserContext) async { ... }

You have to pass the user to every single function!

With @TaskLocal, you just set it once and forget about it:

await $currentUser.withValue(user) {
    await processAPIRequest()  // No parameters needed!
}

Key Benefits

  1. No more parameter passing – Set it once, use it everywhere
  2. Automatic inheritance – Child tasks automatically get the values
  3. Clean code – Functions don’t need extra parameters
  4. Safe – Values are only available in the right scope

Think of @TaskLocal as a helpful assistant that automatically carries important information to everyone who needs it, so you don’t have to!


This content originally appeared on DEV Community and was authored by ArshTechPro