Zhivko Manchev

Senior iOS Engineer

iOS Team Lead

Swift & SwiftUI Expert

Article

Swift Concurrency: Errors & Cancellation

February 2, 2025 Swift-Concurrency
Swift Concurrency Series
Intro to Error Handling

So far, our examples have assumed everything succeeds, but in real applications errors are inevitable. Swift Concurrency makes handling these errors both safer and more explicit.

There are two common categories to consider:

  • User errors – mistakes in implementation, such as forgetting to call a completion handler in a GCD-based function. In async/await, these mistakes are caught by the compiler, because every asynchronous function must either return a value or throw an error, preventing silent failures.
  • Data errors – issues that arise during execution, like network failures or invalid data. With GCD, you often end up nesting multiple blocks inside callbacks, which can become hard to read. Swift Concurrency allows you to use structured "try await" calls, propagating errors naturally while keeping the code linear and easy to follow.
Example 1. User Error: Missing Completion / Return

This example demonstrates a common mistake in GCD-based asynchronous code: forgetting to call the completion handler when a guard fails. Swift Concurrency enforces proper error handling at compile time, ensuring that every path either returns a value or throws an error, preventing silent failures.

func loadData(completion: ((Result<Data, Error>) -> Void)) {
    guard let data = provideOptionalData() else { return }
    // ❌ Mistake: returning early without calling completion leads to silent failure
    
    
    completion(.success(data))
}
func loadData() async throws -> Data {
    guard let data = provideOptionalData() else { return }
    // ❌ Compiler error: Non-void function should return a value
    
    return data
}
func loadData() async throws -> Data {
    guard let data = provideOptionalData() else {
        throw DataError.exampleError
    }
    
    return data
}
Example overview

In the GCD version, forgetting to call the completion handler in every branch of a function can cause silent failures, which are often difficult to detect and debug. Swift Concurrency prevents this type of “user error” by enforcing that every asynchronous function must either return a value or throw an error.

Example 2. Data Error: Sequential Fetch with Error Handling

This example demonstrates handling data errors when fetching multiple resources sequentially. With GCD, error handling leads to nested callbacks, while Swift Concurrency allows structured try/await, keeping the code linear and easy to read.

func fetchAndUpdateOld(person: Person) {
    person.loadImage { image, imageError in
        if let imageError {
            print("Failed to load image: \(imageError)")
            return
        }

        person.loadInvitesCount { count, countError in
            if let countError {
                print("Failed to load invites count: \(countError)")
                return
            }

            person.loadFriends { friends, friendsError in
                if let friendsError {
                    print("Failed to load friends: \(friendsError)")
                    return
                }

                self.updatePersonDetails(image: image, count: count, friends: friends)
            }
        }
    }
}
func fetchAndUpdateNew(person: Person) async {
    do {
        let image = try await person.loadImage()
        let count = try await person.loadInvitesCount()
        let friends = try await person.loadFriends()

        updatePersonDetails(image: image, count: count, friends: friends)
    } catch {
        print("Failed to load data: \(error)")
    }
}
Example overview

In the GCD version, error handling requires nested closures and repeated checks, making sequential asynchronous code harder to read and maintain.

Swift Concurrency allows the same logic to be expressed linearly with try/await, automatically propagating errors to a single catch block. This results in cleaner, safer, and more maintainable code.

Task Cancellation in SwiftUI

In SwiftUI, the ".task" modifier provides a simple way to run asynchronous work tied to a view’s lifecycle. When the view disappears (for example, when navigating away), the associated task is automatically cancelled, preventing unnecessary work and resource usage. This is particularly useful for network requests or long-running computations.

Example 3. Loading an Image with Cancellation

In this example, we have two SwiftUI views:

  • ContentView provides a navigation stack where the user can tap a button to navigate to the ImagePresentView.
  • ImagePresentView displays either a loaded image or a progress indicator while the image is being fetched asynchronously.

The execution flow works as follows: when ImagePresentView appears, the .task modifier triggers ViewModel.loadImage(). This task runs asynchronously, simulating a network delay and fetching the image data. If the user navigates away before the task completes, SwiftUI automatically cancels the task, preventing unnecessary work. The loadImage() function uses try await to handle both network errors and cancellation in a structured, linear way, updating the view only if the operation completes successfully.

struct ContentView: View {
    @State var path = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Text("Hello, world!")
                
                Button("Navigate to image") {
                    path.append("image")
                }
            }
            .padding()
            .navigationDestination(for: String.self) { string in
                if string == "image" {
                    ImagePresentView()
                } else {
                    EmptyView()
                }
            }
        }
    }
}
struct ImagePresentView: View {
    @State private var viewModel = ViewModel()

    var body: some View {
        if let image = viewModel.image {
            Image(uiImage: image)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(maxWidth: 200)
        } else {
            ProgressView()
                .task {
                    await viewModel.loadImage()
                }
        }
    }
}
extension ImagePresentView {
    @Observable
    class ViewModel {
        var image: UIImage?

        private let exampleImageUrl = URL(string: "https://zhivkomanchev.com/wp-content/uploads/2026/02/zm-logo.png")!

        func loadImage() async {
            do {
                // Simulate delay; this task can be cancelled during sleep
                try await Task.sleep(for: .seconds(3))

                // Fetch image data from the network; cancellation throws if task is cancelled
                let (data, _) = try await URLSession.shared.data(from: exampleImageUrl)

                image = UIImage(data: data)
            } catch {
                print(error)
            }
        }
    }
}
Example overview

This example highlights how Swift Concurrency and SwiftUI work together to handle task cancellation safely and efficiently. By tying asynchronous work to the view lifecycle using .task, we avoid wasted network requests and unnecessary computation when a view disappears.

Compared to traditional GCD approaches, this pattern eliminates the need for manual cancellation checks or state tracking, making code simpler, more predictable, and easier to maintain. It demonstrates a key benefit of Swift Concurrency: asynchronous operations can be both safe and lifecycle-aware without extra boilerplate.

Support This Content

If you found this helpful, you can buy me a coffee here.

Tags:
Related Posts
Swift Concurrency: Actors & Data Safety

Swift Concurrency Series Part 1: Basics – Synchronous code, GCD, and async/await fundamentals. Part 2: Parallel Execution – Running independent…

Swift Concurrency: Parallel Tasks

Swift Concurrency Series Part 1: Basics – Synchronous code, GCD, and async/await fundamentals. Part 2: Parallel Execution – Running independent…

Write a comment