Swift Concurrency: Errors & Cancellation
Part 1: Basics - Synchronous code, GCD, and async/await fundamentals.
Part 2: Parallel Execution - Running independent tasks concurrently.
Part 3: Errors & Cancellation - Propagation and task cancellation.
Part 4: Actors - Protecting shared state with actor isolation.
- XC Project - Download the project with all examples used in this series
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.
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 }
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.
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)") } }
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.
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.
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) } } } }
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.
Swift Concurrency Series Part 1: Basics – Synchronous code, GCD, and async/await fundamentals. Part 2: Parallel Execution – Running independent…
Swift Concurrency Series Part 1: Basics – Synchronous code, GCD, and async/await fundamentals. Part 2: Parallel Execution – Running independent…