How to use Task and .task in Swift | Modern Swift Concurrency #4
Every piece of async work in Swift must live inside a Task — it is the unit of concurrent execution, analogous to a thread but far lighter-weight. Understanding how to create, cancel, and tie tasks to view lifetimes is the difference between an app that handles navigation gracefully and one that silently wastes resources fetching data for screens the user already dismissed.
What You'll Learn
- What a
Taskis, how it inherits actor context and priority from its creation site, and whatTask.detacheddoes differently - How to cancel a
Taskmanually using a stored handle, and why the.taskview modifier is almost always the better choice in SwiftUI - How task priority levels work and why spawning high-priority tasks does not guarantee immediate execution
Mental Model
Think of a Task as a work order you hand to a contractor. The contractor (Swift's cooperative thread pool) will start the work as soon as resources are available. You get a job ticket (the Task value) in return. If you need to cancel the work before it's done — say, the client changed their mind — you call task.cancel() with your ticket. If you lose the ticket (the Task value is discarded), the work keeps running autonomously until it finishes or errors out.
The .task view modifier is like hiring a contractor who has a clause in their contract: "if the client (the view) closes up shop, all outstanding work orders are automatically cancelled." You never have to remember to call cancel() yourself — the contract handles it.
Detailed Explanation
A Task is the fundamental unit of asynchronous work in Swift concurrency. Under the hood, it is not a thread — it is a lightweight coroutine that runs cooperatively on Swift's thread pool. Creating a thousand Tasks does not create a thousand threads; the runtime schedules them across a pool sized to the number of available CPU cores.
When you create Task { ... } inside a @MainActor-isolated context (like a SwiftUI view's body or a @MainActor view model), the task inherits the actor context and starts on the main actor. This means the first synchronous work inside the task happens on the main thread. After any await, the task may be rescheduled to a pool thread. Task.detached { ... } explicitly breaks this inheritance — a detached task has no actor context, starts on a pool thread, and does not inherit priority or cancellation scope from its creator.
Task.checkCancellation() and Task.isCancelled are the two ways to check if someone has requested cancellation. Cancellation in Swift is cooperative: calling task.cancel() sets a flag; it does not forcibly stop execution. The async work must periodically check this flag and respond by throwing CancellationError or returning early. URLSession.data(from:) does check cancellation internally — if the parent task is cancelled, the network request is aborted automatically.
The commented-out priority examples in the sample are instructive. Task priorities (high, userInitiated, medium, low, utility, background) hint to the scheduler how urgently the work is needed. Higher-priority tasks generally run sooner, but this is advisory: the scheduler may reorder work. One subtle rule: a child task never runs at lower priority than its parent. If you create a .low priority task and inside it create a subtask, the subtask's effective priority is at least .low.
Task.yield() is a manual suspension point. Calling await Task.yield() says "I'm willing to give other tasks a chance to run before I continue." This is useful inside long-running CPU-bound loops to prevent starving the thread pool. The commented-out .high priority task with await Task.yield() demonstrates this pattern.
The .task view modifier creates a Task whose lifecycle is tied to the view. When the view appears, the task starts. When the view disappears, the task is cancelled. This is implemented by SwiftUI calling task.cancel() in the view's disappearance phase. Because URLSession.data(from:) respects cancellation, any in-flight network request from a .task modifier is automatically aborted when you navigate away.
Code Structure
TaskBootcamp.swift has a home view (TaskBootcampHomeView) with a navigation link to TaskBootcamp. The destination view uses .task to fetch an image with a simulated 5-second delay, demonstrating what happens when you navigate back before the task completes. The commented-out code shows the manual Task/cancel() pattern, the fetchImage2 pattern using two simultaneous tasks, and the priority demonstration.
Complete Code
TaskBootcamp.swift
import SwiftUI
class TaskBootcampViewModel: ObservableObject {
@Published var image: UIImage? = nil
@Published var image2: UIImage? = nil
func fetchImage() async {
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5-second delay simulates a slow network; navigate back during this window to test cancellation
// for x in array {
// // work
// try Task.checkCancellation() // cooperative cancellation: throws CancellationError if task was cancelled, stopping the loop
// }
do {
guard let url = URL(string: "https://picsum.photos/1000") else { return }
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil) // URLSession checks Task.isCancelled internally; aborts if parent task is cancelled
await MainActor.run(body: {
self.image = UIImage(data: data) // hop to main actor before mutating @Published state
print("IMAGE RETURNED SUCCESSFULLY!")
})
} catch {
print(error.localizedDescription) // CancellationError is caught here if the task was cancelled during the network request
}
}
func fetchImage2() async {
do {
guard let url = URL(string: "https://picsum.photos/1000") else { return }
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
await MainActor.run(body: {
self.image2 = UIImage(data: data) // separate @Published property so image and image2 load independently
})
} catch {
print(error.localizedDescription)
}
}
}
struct TaskBootcampHomeView: View {
var body: some View {
NavigationView {
ZStack {
NavigationLink("CLICK ME! 🤓") {
TaskBootcamp() // navigating back while TaskBootcamp is loading will test .task cancellation
}
}
}
}
}
struct TaskBootcamp: View {
@StateObject private var viewModel = TaskBootcampViewModel()
// @State private var fetchImageTask: Task<(), Never>? = nil // stored handle enables manual cancellation in onDisappear
var body: some View {
VStack(spacing: 40) {
if let image = viewModel.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
if let image = viewModel.image2 {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
}
.task { // preferred: SwiftUI automatically cancels this task when TaskBootcamp disappears
await viewModel.fetchImage()
}
// .onDisappear {
// fetchImageTask?.cancel() // manual cancellation: required if you use onAppear+Task instead of .task
// }
// .onAppear {
// fetchImageTask = Task { // store the task handle so it can be cancelled onDisappear
// await viewModel.fetchImage()
// }
// Task {
// print(Thread.current)
// print(Task.currentPriority) // inherits the priority from the enclosing context
// await viewModel.fetchImage2() // starts concurrently with fetchImage
// }
// Task(priority: .high) {
//// try? await Task.sleep(nanoseconds: 2_000_000_000)
// await Task.yield() // voluntarily yields so other tasks can run; useful in CPU-bound work
// print("high : \(Thread.current) : \(Task.currentPriority)")
// }
// Task(priority: .userInitiated) {
// print("userInitiated : \(Thread.current) : \(Task.currentPriority)")
// }
// Task(priority: .medium) {
// print("medium : \(Thread.current) : \(Task.currentPriority)")
// }
// Task(priority: .low) {
// print("low : \(Thread.current) : \(Task.currentPriority)")
// }
// Task(priority: .utility) {
// print("utility : \(Thread.current) : \(Task.currentPriority)")
// }
// Task(priority: .background) {
// print("background : \(Thread.current) : \(Task.currentPriority)")
// }
// Task(priority: .low) {
// print("low : \(Thread.current) : \(Task.currentPriority)")
// Task.detached { // detached: does NOT inherit actor context, priority, or cancellation scope from the parent
// print("detached : \(Thread.current) : \(Task.currentPriority)")
// }
// }
// }
}
}
struct TaskBootcamp_Previews: PreviewProvider {
static var previews: some View {
TaskBootcamp()
}
}Code Walkthrough
The 5-second sleep in
fetchImage()— This delay is intentional as a test harness. Navigate toTaskBootcampvia the home view, then immediately navigate back before 5 seconds have elapsed. With.task, the sleep is cancelled and the network request never fires. WithonAppear { Task { ... } }(no stored handle, noonDisappearcancel), the task runs to completion even though the view is gone — a resource waste that becomes a correctness bug if the callback mutates state on a deallocated view model..taskvs manualTask+onDisappear— The commented-out@State private var fetchImageTaskpattern is the manual equivalent of.task. You store theTaskvalue, cancel it inonDisappear, and start it inonAppear. The.taskmodifier does exactly this internally. The modifier is preferred because it is impossible to forget theonDisappearcancel call, and it handles edge cases like rapid appear/disappear cycles correctly.Task.currentPriorityand priority inheritance — Uncomment the priority blocks inonAppearand run the app. Observe that tasks don't necessarily print in priority order — the scheduler may batch them. Notice thatTask.detachedprints a different priority (.mediumby default) because it does not inherit the enclosing.highpriority task's context.Task.detached— The commented-outTask.detachedblock shows a task with no parent. It does not inherit the actor context (so it runs on a pool thread, not the main actor), does not inherit priority, and is not automatically cancelled when the parent task is cancelled. UseTask.detachedsparingly — it is for work that should run independently of the view's lifecycle, such as a background sync job in an app-level coordinator.Two independent
fetchImagecalls — The commented-out code inonAppearcreates two separateTaskinstances callingfetchImage()andfetchImage2(). Both start concurrently and updateviewModel.imageandviewModel.image2independently. This is one way to kick off concurrent work, butasync let(lesson 5) is cleaner when both results are needed before proceeding.
Common Mistakes
Mistake: Creating a Task in onAppear without storing the handle or calling cancel() in onDisappear Without cancellation, a task launched in onAppear is fire-and-forget. If the view pops off the navigation stack before the task finishes, the task continues running, may update a deallocated view model, and wastes network resources. Always pair onAppear { Task { ... } } with onDisappear { task?.cancel() }, or simply use .task.
Mistake: Using Task.detached inside a SwiftUI view to avoid @MainActor A common anti-pattern is Task.detached { await someBackgroundWork() } when the real goal is just to run background work. Task.detached breaks actor inheritance and makes the task immune to parent cancellation. Instead, use a plain Task { ... } — it inherits the actor context and starts background-capable work at the first await.
Mistake: Reading Task.currentPriority and assuming it reflects scheduling order Task priority is advisory, not deterministic. The runtime uses priority to decide which tasks to schedule sooner, but it also considers factors like priority inversion (a high-priority task waiting on a lower-priority one), thread availability, and fairness. Do not write code that depends on a specific task execution order based solely on priority.
Key Takeaways
- A
Taskis a lightweight unit of work — creating thousands of tasks does not create thousands of threads; the cooperative thread pool manages scheduling automatically - The
.taskSwiftUI modifier is the preferred way to start async work in views because it ties the task's lifetime to the view's lifetime and handles cancellation automatically - Cancellation in Swift is cooperative:
task.cancel()sets a flag, and async functions likeURLSession.data(from:)check that flag internally — long CPU-bound loops must calltry Task.checkCancellation()explicitly
Last updated: June 27, 2026