Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 014 / 100

Task groups and cancellation in Swift, SwiftUI & iOS Apps

Structured concurrency in practice.

Expona Admin8 min read

Introduction

By Day 14 of building production iOS apps, you've likely encountered scenarios where you need to run multiple asynchronous operations in parallel, then coordinate their cleanup when the user dismisses a view or the network request times out. Task groups and cancellation in Swift, SwiftUI & iOS Apps form the backbone of structured concurrency, letting you manage complex workflows without callback hell or race conditions. We'll walk through how to use withThrowingTaskGroup for dynamic parallel work, understand automatic child task cancellation, check for cancellation signals in loops, and tie it all together with SwiftUI's .task modifier for automatic lifecycle management.

Why task groups matter in production apps

Imagine you're building an e-commerce app that needs to fetch product details, inventory, reviews, and pricing from four separate endpoints in parallel. You can't make those calls sequentially—users won't wait five seconds for four serial requests. You also can't spawn four independent Task objects and hope they coordinate properly, because if the user swipes back, you want all four to stop immediately.

Task groups solve this elegantly. They group child tasks under a parent, so cancelling the parent automatically cancels all children. They also provide a structured way to collect results from dynamic numbers of concurrent operations—essential when you're paginating through results or processing a variable-length queue of work.

withThrowingTaskGroup for dynamic parallel work

withThrowingTaskGroup is the workhorse for running multiple async operations concurrently and collecting their results. Unlike withTaskGroup, it propagates errors from child tasks back to the parent, so you don't lose exceptions in a sea of parallel work.

Here's a real-world example: fetching multiple resources for a travel booking screen.

swift
struct BookingViewModel {
    @MainActor
    func loadBookingDetails(tripID: String) async throws {
        try await withThrowingTaskGroup(of: BookingData.self) { group in
            group.addTask {
                try await fetchFlightDetails(tripID: tripID)
            }
            group.addTask {
                try await fetchHotelDetails(tripID: tripID)
            }
            group.addTask {
                try await fetchCarRentalDetails(tripID: tripID)
            }
            
            var results: [BookingData] = []
            for try await result in group {
                results.append(result)
            }
            
            self.flightInfo = results.first(where: { $0.type == .flight })
            self.hotelInfo = results.first(where: { $0.type == .hotel })
            self.carInfo = results.first(where: { $0.type == .car })
        }
    }
}

The pattern is straightforward: create the group, add tasks with group.addTask { ... }, then iterate over results with for try await. The key advantage is that all three network requests start immediately and run in parallel, but the function doesn't return until all results are collected or an error occurs.

In a CRM system where you're syncing customer data, contacts, and recent interactions from three separate services, the same pattern keeps those fetches fast and coordinated:

swift
func syncCustomerContext(customerID: String) async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask {
            try await updateCustomerProfile(customerID)
        }
        group.addTask {
            try await syncContacts(customerID)
        }
        group.addTask {
            try await syncRecentInteractions(customerID)
        }
        
        try await group.waitForAll()
    }
}

Here we don't care about collecting results—we just want all three operations to complete or fail together. Using group.waitForAll() keeps it clean.

Child tasks cancel with the parent

Structured concurrency in Swift guarantees that if a parent task is cancelled, all child tasks are cancelled too. This is enforced by the runtime, so you don't have to manually track and cancel each child.

Consider a ride-hailing app where you're estimating fares from multiple service partners while the user is deciding whether to book:

swift
@MainActor
class RideEstimateViewModel: ObservableObject {
    @Published var estimates: [ServiceEstimate] = []
    private var estimateTask: Task<Void, Never>?
    
    func startFareEstimates(pickup: Location, dropoff: Location) {
        estimateTask = Task {
            do {
                try await withThrowingTaskGroup(of: ServiceEstimate.self) { group in
                    for service in Service.allCases {
                        group.addTask {
                            try await fetchEstimate(service: service, from: pickup, to: dropoff)
                        }
                    }
                    
                    for try await estimate in group {
                        self.estimates.append(estimate)
                    }
                }
            } catch is CancellationError {
                print("Estimates cancelled—user likely dismissed the screen")
            } catch {
                print("Error fetching estimates: \(error)")
            }
        }
    }
    
    func cancelEstimates() {
        estimateTask?.cancel()
    }
    
    deinit {
        estimateTask?.cancel()
    }
}

When estimateTask?.cancel() is called, every child task inside the task group receives a cancellation signal. They don't all stop instantly—they stop when they check for cancellation—but the runtime ensures no new child tasks are added after cancellation begins.

This automatic propagation is why you should avoid spawning independent Task objects for operations that should be coordinated. Task groups give you structure and guarantees; loose tasks don't.

Check Task.isCancelled in long loops

Cancellation isn't instantaneous. If a child task is processing data in a loop, it won't notice it's been cancelled until it hits an `await` point or checks Task.isCancelled. For long-running synchronous work, you must check manually.

Here's a realistic scenario: a trading app ingesting and processing a large batch of historical price data:

swift
func processHistoricalPrices(priceData: [Double]) async throws {
    try await withThrowingTaskGroup(of: PriceAnalysis.self) { group in
        let chunkSize = 1000
        
        for chunk in priceData.chunked(into: chunkSize) {
            group.addTask {
                var analysis = PriceAnalysis()
                
                for (index, price) in chunk.enumerated() {
                    // Check for cancellation periodically during CPU-bound work
                    if index % 100 == 0 {
                        try Task.checkCancellation()
                    }
                    
                    analysis.update(with: price)
                }
                
                return analysis
            }
        }
        
        for try await analysis in group {
            self.results.append(analysis)
        }
    }
}

The call to try Task.checkCancellation() throws a CancellationError if the task has been cancelled, allowing the async function to unwind cleanly. You can also use Task.isCancelled as a boolean check if you prefer to handle it differently:

swift
for (index, item) in items.enumerated() {
    if Task.isCancelled {
        break
    }
    processItem(item)
}

The rule of thumb: if you're doing CPU-intensive work or a tight loop without await, check cancellation every few iterations. If you're hitting await frequently (network requests, database queries), the runtime will naturally surface cancellation.

Use .task in SwiftUI for automatic cancellation

SwiftUI's .task modifier is where structured concurrency shines in practice. When you attach a task to a view, SwiftUI automatically cancels it when the view disappears. You never have to manually call .cancel() or manage a deinit.

swift
struct BookingDetailsView: View {
    @StateObject var viewModel = BookingViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.flightInfo?.name ?? "Loading...")
            Text(viewModel.hotelInfo?.name ?? "Loading...")
        }
        .task {
            do {
                try await viewModel.loadBookingDetails(tripID: "ABC123")
            } catch {
                print("Failed to load booking: \(error)")
            }
        }
    }
}

When BookingDetailsView is dismissed, SwiftUI automatically cancels the task. All child tasks inside the task group receive cancellation signals. No manual cleanup needed. This is the pattern we use across production e-commerce and SaaS apps because it's predictable and safe.

You can also pass dependencies to the task so it re-runs when they change:

swift
struct CustomerDetailsView: View {
    let customerID: String
    @StateObject var viewModel = CustomerViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.customerName)
        }
        .task(id: customerID) {
            do {
                try await viewModel.loadCustomer(id: customerID)
            } catch {
                viewModel.errorMessage = error.localizedDescription
            }
        }
    }
}

The id: customerID tells SwiftUI: if customerID changes, cancel the old task and start a new one. Perfect for handling navigation or filtering changes.

Common mistakes

  • Ignoring cancellation in loops: Don't assume Task.isCancelled will be checked automatically during synchronous work. You must check it periodically if you have tight loops or CPU-intensive processing.
  • Spawning independent tasks instead of grouping: Using Task { ... } instead of group.addTask { ... } loses coordination. If the parent is cancelled, independent tasks keep running, leading to memory leaks and stale data updates.
  • Forgetting to iterate the group: If you add tasks to a group but never iterate or call waitForAll(), the results aren't collected and the group never completes. Always consume the group's results.
  • Not propagating errors from .task: Errors thrown in a .task closure should be caught and handled (or logged). If you silently ignore them, debugging becomes painful in production.
  • Using .onAppear for long-running work: .onAppear doesn't auto-cancel when the view disappears. Always use .task for async work in SwiftUI.

Key takeaways

  • withThrowingTaskGroup runs multiple async operations in parallel and collects results safely, with error propagation built in.
  • Child tasks are automatically cancelled when the parent is cancelled, ensuring coordinated cleanup without manual tracking.
  • Check Task.isCancelled or call try Task.checkCancellation() periodically during synchronous loops to respond to cancellation signals promptly.
  • SwiftUI's .task modifier automatically cancels tasks when views disappear, making it the preferred pattern for view-driven async work.
  • Structured concurrency prevents common concurrency bugs by enforcing parent-child relationships; avoid loose Task spawning for coordinated work.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles