While you can use tasks without any reference, you will run into scenarios where you want to keep a reference to a task and cancel it if the result is no longer needed.

Unlike canceling URLSessionTask instances or Combining publishers, a concurrency task will not necessarily cancel if you call to cancel it. This is due to tasks being responsible for checking for cancellations manually. Let’s dive into the details!

Cancelling a task

To explain to you how cancellation works, we’re going to work with a new code example that allows us to load an image in SwiftUI:

struct ContentView: View {
    @State var image: UIImage?

    var body: some View {
        VStack {
            if let image {
                Image(uiImage: image)
            } else {
                Text("Loading...")
            }
        }.onAppear {
            Task {
                do {
                    image = try await fetchImage()
                    print("Image loading completed")
                } catch {
                    print("Image loading failed: \(error)")
                }
            }
        }
    }

    func fetchImage() async throws -> UIImage? {
        let imageTask = Task { () -> UIImage? in
            let imageURL = URL(string: "https://httpbin.org/image")!
            var imageRequest = URLRequest(url: imageURL)
            imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

            print("Starting network request...")
            let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

            return UIImage(data: imageData)
        }
        return try await imageTask.value
    }
}

The above code example fetches a random image and displays it accordingly if the request succeeds. Note that we don’t need the extra task wrapper, we only do it to demonstrate the cancellation process.

Therefore, for the sake of this demo, we could cancel the imageTask right after its creation:

func fetchImage() async throws -> UIImage? {
    let imageTask = Task { () -> UIImage? in
        let imageURL = URL(string: "https://httpbin.org/image")!
        var imageRequest = URLRequest(url: imageURL)
        imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

        print("Starting network request...")
        let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

        return UIImage(data: imageData)
    }
    // Cancel the image request right away:
    imageTask.cancel()
    return try await imageTask.value
}

The cancellation call above is enough to stop the request from succeeding since the URLSession implementation performs cancellation checks before execution. Therefore, the above code example is printing out the following:

Starting network request...
Image loading failed: Error Domain=NSURLErrorDomain Code=-999 "cancelled"

As you can see, our print statement is still being executed. This print statement is a great way to demonstrate how to implement cancellation checks using one of the two available static cancellation check methods.

Task cancellation checking using Task.checkCancellation()

The first way of checking for a cancellation is by calling the static Task.checkCancellation() method. This method throws a CancellationError() as a way to prevent further execution.

In our example, we can implement it as follows:

let imageTask = Task { () -> UIImage? in
    let imageURL = URL(string: "https://httpbin.org/image")!
    var imageRequest = URLRequest(url: imageURL)
    imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

    /// Throw an error if the task was already cancelled.
    try Task.checkCancellation()

    print("Starting network request...")
    let (imageData, _) = try await URLSession.shared.data(for: imageRequest)
    return UIImage(data: imageData)
}

// Cancel the image request right away:
imageTask.cancel()

This way, we will stop the task from continuing after creating the URLRequest and detecting a cancellation.

The above code results print:

Image loading failed: CancellationError()

As you can see, both our print statement and network requests don’t get called.

Boolean based Task cancellation checking

The second method allows us to check for cancellation but handle the result manually. This can be useful if you don’t want the task to fail but return a default value instead.

In the following code example, we’re running a print statement followed by returning a nil value:

let imageTask = Task { () -> UIImage? in
    let imageURL = URL(string: "https://httpbin.org/image")!
    var imageRequest = URLRequest(url: imageURL)
    imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

    guard Task.isCancelled == false else {
        // Perform clean up
        print("Image request was cancelled")
        return nil
    }

    print("Starting network request...")
    let (imageData, _) = try await URLSession.shared.data(for: imageRequest)
    return UIImage(data: imageData)
}

// Cancel the image request right away:
imageTask.cancel()

In this case, our code only prints out:

Image request was cancelled

Performing regular cancellation checks is essential to prevent your code from doing unnecessary work. Imagine an example in which we would transform the returned image; we should’ve probably added multiple checks throughout our code:

let imageTask = Task { () -> UIImage? in
    let imageURL = URL(string: "https://httpbin.org/image")!
    var imageRequest = URLRequest(url: imageURL)
    imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

    // Check for cancellation before the network request.
    try Task.checkCancellation()

    print("Starting network request...")
    let (imageData, _) = try await URLSession.shared.data(for: imageRequest)

    // Check for cancellation after the network request
    // to prevent starting our heavy image operations.
    try Task.checkCancellation()

    let image = UIImage(data: imageData)

    // ... Perform heavy image operations since the task is not cancelled.

    return image
}

We are in control regarding cancellation, making it easy to make mistakes and perform unnecessary work. Please keep an eye sharp when implementing tasks to make sure your code regularly checks for cancellation states.

Optimizing the code example

Now that we know how to check for cancellations and cancel individual tasks, it’s time to optimize the image loading code example. There are a few improvements we can make:

It’s crucial to add a do-catch block around the URLSession method, as it internally also checks for cancellation and throws a CancellationError if needed.

Altogether, the code example looks as follows:

let imageTask = Task {
    let fallbackImage = UIImage(systemName: "fallback_image")!

    let imageURL = URL(string: "https://httpbin.org/image")!
    var imageRequest = URLRequest(url: imageURL)
    imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

    /// Check for cancellation before the network request starts.
    guard !Task.isCancelled else {
        return fallbackImage
    }

    print("Starting network request...")

    let imageData: Data
    do {
        let (data, _) = try await URLSession.shared.data(for: imageRequest)
        imageData = data
    } catch is CancellationError {
        /// URLSession throws a CancellationError if the task is cancelled while awaiting.
        /// Return the fallback image instead of propagating the error.
        return fallbackImage
    }

    /// Check for cancellation after the network request.
    /// Potentially, you would perform image operations on `imageData`
    guard !Task.isCancelled else {
        return fallbackImage
    }

    /// ... Perform heavy image operations on `imageData` since the task is not cancelled.

    guard let image = UIImage(data: imageData) else {
        /// Converting the data to `UIImage` failed, return our fallback image.
        return fallbackImage
    }

    /// We completed the image download and heavy operations without cancellations, return the image.
    return image
}

We’ve now optimized the body of this task, but there’s a little more to discover when using tasks in SwiftUI.

Using tasks in SwiftUI

As mentioned before, we don’t need the extra Task wrapper. It’s a way to keep a reference to the task and cancel it if needed. When loading images in SwiftUI, there are many scenarios where you won’t implement an explicit cancel button in the UI. Instead, you want a running task canceled when navigating away from the view.

We start this journey by changing our image fetching method as follows:

func fetchImage() async throws -> UIImage {
    let fallbackImage = UIImage(named: "fallback_image")!

    let imageURL = URL(string: "https://httpbin.org/image")!
    var imageRequest = URLRequest(url: imageURL)
    imageRequest.allHTTPHeaderFields = ["accept": "image/jpeg"]

    /// Check for cancellation before the network request starts.
    guard !Task.isCancelled else {
        return fallbackImage
    }

    print("Starting network request...")
    let imageData: Data
    do {
        let (data, _) = try await URLSession.shared.data(for: imageRequest)
        imageData = data
    } catch is CancellationError {
        /// URLSession throws a CancellationError if the task is cancelled while awaiting.
        /// Return the fallback image instead of propagating the error.
        return fallbackImage
    }

    /// Check for cancellation after the network request.
    /// Potentially, you would perform image operations on `imageData`
    guard !Task.isCancelled else {
        return fallbackImage
    }

    /// ... Perform heavy image operations on `imageData` since the task is not cancelled.

    guard let image = UIImage(data: imageData) else {
        /// Converting the data to `UIImage` failed, return our fallback image.
        return fallbackImage
    }

    /// We completed the image download and heavy operations without cancellations, return the image.
    return image
}

We’ve removed the wrapper, and we’re now running directly in the body of the fetchImage() method.

Next, we want to cancel the image downloading task when a view disappears. I hear you thinking, you probably wanted to implement something like:

struct ContentView: View {
    @State var image: UIImage?

    // Keep a reference to the task so we can cancel it if needed.
    @State var imageDownloadingTask: Task<Void, Never>?

    var body: some View {
        VStack {
            if let image {
                Image(uiImage: image)
            } else {
                Text("Loading...")
            }
        }.onAppear {
            imageDownloadingTask = Task {
                do {
                    image = try await fetchImage()
                    print("Image loading completed")
                } catch {
                    print("Image loading failed: \(error)")
                }
            }
        }.onDisappear {
            imageDownloadingTask?.cancel()
        }
    }

    func fetchImage() async throws -> UIImage {
        // ...
    }
}

While this works fine, there’s a way to let SwiftUI handle this altogether. We can do this by making use of the task modifier:

struct ContentView: View {
    @State var image: UIImage?

    var body: some View {
        VStack {
            if let image {
                Image(uiImage: image)
            } else {
                Text("Loading...")
            }
        }.task {
            do {
                image = try await fetchImage()
                print("Image loading completed")
            } catch {
                print("Image loading failed: \(error)")
            }
        }
    }
}

You can use this modifier to perform an async task with a lifetime that matches that of the modified view. If the task doesn’t finish before SwiftUI removes the view or the view changes identity, SwiftUI cancels the task.

Another benefit is that the task gets scheduled on the cooperative pool as early as possible—sooner than if you were to start it inside an onAppear closure. However, the exact execution time still depends on the underlying task executor. As a result, regular code within onAppear might run earlier. This isn’t a contradiction—it simply reflects the timing decisions made by the executor. The following code illustrates this behavior:

SomeView()
    .task(priority: .high, {
        /// Executes earlier than a task scheduled inside `onAppaer`.
        print("1")
    })
    .onAppear {
        /// Scheduled later than using the `task` modifier which
        /// adds an asynchronous task to perform before the view appears.
        Task(priority: .high) { print("2") }

        /// Regular code inside `onAppear` might appear to run earlier than a `Task`.
        /// This is due to the task executor scheduler.
        print("3")
    }

Child tasks and cancellation

If a parent task is canceled, all of its child tasks are also automatically notified about the cancellation. Child tasks implicitly link to their parent, meaning their cancellation state links as well.

If the parent task is canceled, all child tasks receive the cancellation signal and are expected to check for cancellation using methods like Task.isCancelled.

If a child task doesn’t explicitly check for cancellation, it may continue running. However, any attempt to call try on a throwing function (e.g., try await someThrowingFunction()) that respects cancellation will throw the CancellationError we’ve seen before.

Here’s an example to demonstrate this concept:

func parentTaskExample() async {
    let handle = Task {
        print("Parent task started")

        async let childTask1 = someWork(id: 1)
        async let childTask2 = someWork(id: 2)

        let finishedTaskIDs = try await [childTask1, childTask2]
        print(finishedTaskIDs)
    }

    /// Cancel parent task after a short delay of 0.5 seconds.
    try? await Task.sleep(nanoseconds: 500_000_000)

    /// This cancels both childTask1 and childTask2:
    handle.cancel()

    /// Wait for the parent task and notice how cancellation propagates.
    try? await handle.value
    print("Parent task finished")
}

func someWork(id: Int) async throws -> Int {
    for i in 1...5 {
        /// Check for cancellation and throw an error if detected.
        try Task.checkCancellation()
        print("Child task \(id): Step \(i)")

        /// Sleep for 0.4 seconds.
        try await Task.sleep(nanoseconds: 400_000_000)
    }

    return id
}

If we would not cancel the task right away, the output would be as follows:

Parent task started
Child task 1: Step 1
Child task 2: Step 1
Child task 1: Step 2
Child task 2: Step 2
Child task 1: Step 3
Child task 2: Step 3
Child task 1: Step 4
Child task 2: Step 4
Child task 1: Step 5
Child task 2: Step 5
[1, 2]
Parent task finished

With cancellation in place, however, the output will be something like:

Parent task started
Child task 1: Step 1
Child task 2: Step 1
Child task 2: Step 2
Child task 1: Step 2
Parent task finished

In other words, we canceled the parent task, which automatically canceled all child tasks.

Summary

When working with tasks, you’re required to think about cancellation. By keeping a reference to a task, you can cancel it if needed. However, the body of a task must contain explicit cancellation checks to stop performing work on cancellation.

In the next lesson, we’ll be looking into error handling for tasks. Even though we briefly covered it already, there’s some dedicated knowledge to learn!