Introduction

Swift's TaskGroup provides structured concurrency for running multiple tasks in parallel. However, error handling with task groups is nuanced: withThrowingTaskGroup cancels all remaining children when the first child throws, while withTaskGroup requires manual error collection. Understanding this behavior is critical for building resilient concurrent code.

Symptoms

  • One failing task cancels all other in-flight tasks unexpectedly
  • Partial results from successful tasks are lost when one task fails
  • for await loop over task group exits early on first error
  • Child task errors do not propagate to the parent as expected
  • Cancellation of parent task does not properly cancel children

Example problematic code: ```swift // All tasks cancelled when the first one throws let results = try await withThrowingTaskGroup(of: Item.self) { group in for url in urls { group.addTask { try await fetchItem(from: url) // If this throws, all other tasks are cancelled } }

var results: [Item] = [] for try await item in group { results.append(item) } return results } ```

Common Causes

  • Using withThrowingTaskGroup when partial results are acceptable
  • Not handling individual task errors before they reach the group
  • Assuming withThrowingTaskGroup collects all errors
  • Child tasks throw different error types than expected
  • Group exits early when break is called in for await loop

Step-by-Step Fix

  1. 1.Collect partial results with error tracking:
  2. 2.```swift
  3. 3.func fetchAllItems(from urls: [URL]) async -> (items: [Item], errors: [Error]) {
  4. 4.await withTaskGroup(of: Result<Item, Error>.self) { group in
  5. 5.var items: [Item] = []
  6. 6.var errors: [Error] = []

for url in urls { group.addTask { do { let item = try await fetchItem(from: url) return .success(item) } catch { return .failure(error) } } }

for await result in group { switch result { case .success(let item): items.append(item) case .failure(let error): errors.append(error) } }

return (items, errors) } } ```

  1. 1.Fail fast with withThrowingTaskGroup when all results are required:
  2. 2.```swift
  3. 3.func fetchAllRequired(from urls: [URL]) async throws -> [Item] {
  4. 4.try await withThrowingTaskGroup(of: Item.self) { group in
  5. 5.var results: [Item] = []

for url in urls { group.addTask { try await fetchItem(from: url) } }

for try await item in group { results.append(item) }

return results } // If any task throws, the entire group is cancelled and the error propagates } ```

  1. 1.Handle specific errors without cancelling the group:
  2. 2.```swift
  3. 3.func processWithRecovery(from urls: [URL]) async -> [Item] {
  4. 4.await withTaskGroup(of: Item?.self) { group in
  5. 5.var results: [Item] = []

for url in urls { group.addTask { do { return try await fetchItem(from: url) } catch FetchError.notFound { print("Not found: \(url) - skipping") return nil } catch FetchError.rateLimited { // Retry with backoff try? await Task.sleep(for: .seconds(5)) return try? await fetchItem(from: url) } catch { print("Unexpected error for \(url): \(error)") return nil } } }

for await item in group { if let item = item { results.append(item) } }

return results } } ```

  1. 1.Properly cancel child tasks on parent cancellation:
  2. 2.```swift
  3. 3.func fetchWithTimeout(from urls: [URL], timeout: Duration) async -> [Item] {
  4. 4.await withTaskGroup(of: Item.self) { group in
  5. 5.for url in urls {
  6. 6.group.addTask {
  7. 7.// Check for cancellation before starting work
  8. 8.try? Task.checkCancellation()

let item = try? await fetchItem(from: url)

// Check for cancellation after work try? Task.checkCancellation()

return item! } }

var results: [Item] = [] for await item in group { if Task.isCancelled { group.cancelAll() // Cancel remaining children break } results.append(item) }

return results } } ```

Prevention

  • Use withTaskGroup (not throwing) when partial results are acceptable
  • Wrap individual task errors in Result types to prevent group cancellation
  • Use withThrowingTaskGroup when all tasks must succeed
  • Always check Task.isCancelled in long-running child tasks
  • Document whether a function is fail-fast or partial-results
  • Add structured concurrency tests that verify error propagation behavior