Chapter 3 — Tasks: launching and awaiting work

What a task is

  1. To bridge synchronous code into async — the button handler, viewDidLoad, an app-launch hook —
  2. To run work concurrently — though for that, structured concurrency (Part II) is almost always
// The basic shape: a Task launched from synchronous code.
Task {
    let profile = try await client.fetch(Profile.self, from: profileURL)
    print(profile.name)
}
// This line runs IMMEDIATELY — the Task's body runs concurrently, not now.
print("launched")   // may print before or after the fetch completes

Tasks inherit their context

flowchart TB CTX["Context that creates the Task
(e.g. a @MainActor view)"] CTX -->|"inherits"| A["Actor isolation
(runs on the same actor)"] CTX -->|"inherits"| P["Priority
(same priority as the creator)"] CTX -->|"inherits"| T["Task-local values
(propagated down)"] A --> TASK["Task { }"] P --> TASK T --> TASK
// Inside a SwiftUI View (which is @MainActor):
Button("Load") {
    Task {
        let profile = try? await client.fetch(Profile.self, from: profileURL)
        self.profile = profile     // ✅ safe — this Task inherited @MainActor, so we're on main
    }
}

Task.detached: inherits nothing

// Inside the same @MainActor view:
Task.detached {
    let profile = try? await client.fetch(Profile.self, from: profileURL)
    self.profile = profile    // ❌ ERROR in Swift 6 — this is NOT on the main actor
}

Getting a task's result

// A Task<Success, Failure> is a handle you can await.
let task = Task {
    try await client.fetch(Profile.self, from: profileURL)
}

// … do other things while it runs …

let profile = try await task.value     // await the result (rethrows the task's error)
  • task.valueawaits and returns the success value, rethrowing if the task threw.
  • task.resultawaits and returns a Result<Success, Failure> you can switch on without try.

Priorities

Task(priority: .background) {
    await prefetchNextPage()
}

The catch: Task { } is unstructured

func onAppear() {
    Task {
        await longRunningWork()   // this keeps running even after onAppear() returns
    }
    // onAppear returns HERE, but the task lives on. Who cancels it? You must.
}
  1. Hold the handle if you might need to cancel it. Store the Task (e.g., in a property) and call
  2. Prefer structured concurrency for concurrent work. When work has a clear scope — "do these
flowchart TB subgraph UN__Unstructured__Task_______ [\"Unstructured (Task { })\"] U1["you launch it"] --> U2["you must cancel it"] U2 --> U3["you must manage its lifetime"] end subgraph ST__Structured__async_let___groups___ [\"Structured (async let / groups)\"] S1["scope launches children"] --> S2["scope auto-waits"] S2 --> S3["scope auto-cancels on exit/error"] end

What we built in this chapter

  • Established the task as the fundamental unit of async work — every await runs inside one — and
  • Learned that Task { } inherits actor isolation, priority, and task-local values from its
  • Contrasted Task.detached { }, which inherits nothing, and established that it's a deliberate
  • Saw a task as a handle (task.value / task.result) for start-now-collect-later, and used
  • Confronted the key hazard: Task { } is unstructured — its lifetime isn't bound to any scope — so

Mental model to take away

  • A task is a cheap, runtime-scheduled unit of async work; the async equivalent of a thread, but
  • Task { } inherits context (crucially, actor isolation) — that inheritance is what makes UI
  • Unstructured tasks are your responsibility: nothing waits for or cancels them automatically. Use
  • Let priority be inherited unless you specifically know a piece of work is more or less urgent than