You’re Probably Cancelling Your Swift Tasks Incorrectly

Written by element | Published 2025/04/21
Tech Story Tags: swift | swiftui | swift-programming | programming | concurrent-programming | asynchronous-programming | ios-app-development | ios-development

TLDRSwift’s structured concurrency has transformed the way we write asynchronous code. To fully leverage Swift’S concurrency, you need a solid grasp of how tasks are created and managed.via the TL;DR App

Swift’s structured concurrency has transformed the way we write asynchronous code. With async/await, expressing complex logic becomes cleaner and more linear. However, to fully leverage Swift’s concurrency, you need a solid grasp of how tasks are created and managed — particularly Task, .task, and their variations.


Task: Entering an Async Context from Sync Code

The most direct way to launch asynchronous code from synchronous code in Swift is with Task.

Task {
    let profile = try await userService.fetchUserProfile()
    print("Name:", profile.name)
}

This creates a concurrent task that begins executing immediately. Inside, you can call async functions, use try, and handle cancellation. You can also store the task in a variable to cancel or await its result later:

let task1 = Task {
    try await userService.fetchUserProfile()
}
try await task1.value

let task2 = Task {
    try await userService.fetchUserProfile()
}
task2.cancel()

Tasks created this way inherit the actor context they are declared in — commonly MainActor when used in the UI.


.task: Async Work in SwiftUI Views

In SwiftUI, the idiomatic way to launch asynchronous work when a view appears is using the .task view modifier:

Text("Loading...")
    .task {
        await userService.fetchUserProfile()
    }

This modifier starts a structured task that is tied to the view’s lifecycle, ensuring it is automatically cancelled when the view disappears or when its state changes. When the .task(id:) value changes, it cancels the previous task and starts a new one. However, cancellation only sends a signal — it doesn’t guarantee the task will actually stop unless the code inside is cancellation-aware.


Cancellation Awareness

Swift’s concurrency model is cancellation-aware. When SwiftUI cancels a .task, or when you cancel a task manually using task.cancel(), that doesn't magically stop your code. The task is marked as cancelled, but cancellation only takes effect when your code checks for it or uses suspending calls that throw CancellationError. So, cancellation is cooperative — your task must opt in to it.

Here's an example where cancellation works as expected:

.task {
    do {
        try await Task.sleep(for: .seconds(1))
        print("1 second passed")
    } catch is CancellationError {
        print("Sleep was cancelled")
    } catch let error {
        print("Error: \(error)")
    }
}

If you're working with async code that doesn’t throw, you can check Task.isCancelled manually:

.task {
    await performSomeWork()

    if Task.isCancelled {
        return  // Exit early if no longer needed
    }

    await performMoreWork()
}


Using withTaskCancellationHandler

When you need to perform immediate cleanup or handle cancellation in long-running operations, withTaskCancellationHandler gives you precise control.

Here’s a complete example:

func fetchData() async throws {
    try await withTaskCancellationHandler(
        operation: {
            print("🔄 Starting data fetch...")
            try await Task.sleep(for: .seconds(3))
            print("✅ Data fetched successfully!")
        },
        onCancel: {
            print("🛑 Task was cancelled. Cleaning up resources.")
        }
    )
}

// Launch the cancellable task
let task = Task {
    try await fetchData()
}

// Simulate cancellation after 1 second
Task {
    try await Task.sleep(for: .seconds(1))
    task.cancel()
}

💡 What’s happening here:

  • The first task begins a simulated long-running operation.
  • After 1 second, the second task cancels the first one.
  • The onCancel closure runs immediately when the task is cancelled, even if the inner operation is still ongoing.

This is a precise, predictable way to control task cancellation beyond just try await + catch.


Task Priorities

You can assign explicit priorities to tasks using either Task(priority:) for manual tasks, or .task(priority:) when working in SwiftUI:

Task(priority: .background) {
    await userService.fetchUserProfile()
}

.task(priority: .background) {
    await userService.fetchUserProfile()
}

Priorities such as .userInitiated, .background, .utility, etc. serve as scheduling hints to help the system prioritize execution under load. However, they do not affect the thread or actor context where the task runs. For example, a .task(priority: .background) launched from a SwiftUI view still runs on the MainActor unless explicitly detached. If you need true background isolation, you must use Task.detached(...).


.task(priority:) vs Task.detached(priority:)

There’s a critical distinction:

.task(priority: .background) {
    let profile = await userService.fetchUserProfile()
    name = profile.name
}

This task still runs within SwiftUI’s MainActor context. It is safe to access @State@Environment, or any UI-bound data.

In contrast:

Task.detached(priority: .background) {
    let profile = await userService.fetchUserProfile()
    await MainActor.run {
        name = profile.name
    }
}

Task.detached creates a task that does not inherit the current actor or task-local values.

It is designed for truly independent background work.

Using it improperly inside SwiftUI may cause UI updates from the wrong thread.


Awaiting Task Results with .value

You can create a task and later await its result explicitly:

let task = Task {
    return try await userService.fetchUserProfile()
}

do {
    let profile = try await task.value
    print("Name:", profile.name)
} catch {
    print("Error:", error)
}

This gives you control over when the task is awaited — useful for coordination or deferring work.


Common Pitfalls

Swift’s concurrency is powerful, but there are a few subtle traps developers often fall into. Below are common mistakes to watch out for when working with Task and .task in real-world code.

Ignoring Cancellation with try?

This is a typical example where cancellation seems to work — but doesn't

.task {
    try? await Task.sleep(for: .seconds(1))
    print("Still printed")
}

Here, the task is cancellable in theory — Task.sleep throws on cancellation — but try? suppresses the error, including CancellationError. So even if the view disappears or the task is explicitly cancelled, the error is swallowed, and the remaining code continues to execute.

This makes the task look cancellable, but it won’t actually stop when it should. Always prefer do/catch over try? when cancellation matters.

Ignoring Errors in Task

This compiles:

Task {
    try await userService.fetchUserProfile()
}

But if fetchUserProfile() throws, the error is ignored. The task silently fails, which can be misleading or dangerous. Always handle errors explicitly:

Task {
    do {
        try await userService.fetchUserProfile()
    } catch {
        print("Failed:", error)
    }
}

Misunderstanding Task Priority

Setting a task’s priority, like .task(priority: .background), does not move the task to a background thread or actor. If launched inside a SwiftUI view, it still runs on the MainActor, unless explicitly detached.

This is a common misconception. If you need true isolation from the UI thread, use Task.detached — and don’t forget to switch back to MainActor if you update the UI.

Conclusion

Task and .task are powerful building blocks for structured, safe, and cancellable concurrency in Swift. When used properly:

  • Use .task for view-bound async work.
  • Use Task {} for imperative async calls.
  • Use Task.detached when you truly need to isolate work from your current context.
  • Always handle cancellation and errors explicitly.
  • Use withTaskCancellationHandler when you need precise control over cleanup logic and cancelling task.
  • Remember that task priority is just a scheduling hint, not a thread/context control.

And of course — this is far from everything you can do with Task. Its capabilities go much deeper: child tasks, task groups, task-local values, cooperative cancellation, custom executors, and more. I encourage you to experiment, read the official documentation, and explore concurrency beyond the surface level.


Written by element | Senior iOS Engineer with over 12 years of experience developing scalable, user-focused apps using Swift, SwiftUI, UIKit, and more.
Published by HackerNoon on 2025/04/21