Why parallel async work matters

Most iOS apps spend a lot of time waiting — network calls, database reads, file I/O. Doing those sequentially wastes the device’s multicore CPU and makes the UI feel sluggish. Swift’s structured concurrency gives us a clean way to launch several asynchronous operations at once, wait for all of them, and still keep cancellation and error handling predictable.

Enter TaskGroup

A TaskGroup creates a child‑task scope. Each child runs independently, but the group guarantees that the parent task doesn’t finish until every child completes or is cancelled. This is a huge step up from manually juggling Task objects and awaiting them one by one.

Key point: TaskGroup automatically propagates cancellation. If the parent task is cancelled, every child receives the signal without extra boilerplate.

Real‑world scenario: loading a profile screen

Imagine a profile view that needs three pieces of data:

  • User profile (name, avatar)
  • Recent posts
  • Friend suggestions
All three come from different API endpoints. Fetching them sequentially adds up to several seconds of latency. With TaskGroup we can fire them in parallel and update the UI as soon as the slowest request finishes.

Production‑ready example

import Foundation

struct ProfileData {
    let user: User
    let posts: [Post]
    let suggestions: [User]
}

final class ProfileLoader {
    private let api = APIClient.shared
    
    /// Loads all profile components concurrently.
    func loadProfile(userID: String) async throws -> ProfileData {
        // The group’s generic type is the result we want from each child.
        try await withThrowingTaskGroup(of: (String, Any).self) { group in
            // 1️⃣ User profile
            group.addTask {
                let user = try await self.api.fetchUser(id: userID)
                return ("user", user)
            }
            
            // 2️⃣ Recent posts
            group.addTask {
                let posts = try await self.api.fetchPosts(for: userID)
                return ("posts", posts)
            }
            
            // 3️⃣ Friend suggestions
            group.addTask {
                let suggestions = try await self.api.fetchFriendSuggestions(for: userID)
                return ("suggestions", suggestions)
            }
            
            // Collect results as they finish.
            var user: User?
            var posts: [Post]?
            var suggestions: [User]?
            
            for try await (key, value) in group {
                switch key {
                case "user":
                    user = value as? User
                case "posts":
                    posts = value as? [Post]
                case "suggestions":
                    suggestions = value as? [User]
                default:
                    break // ignore unknown keys
                }
            }
            
            // All three must be present — otherwise the API contract changed.
            guard let user, let posts, let suggestions else {
                throw ProfileError.incompleteData
            }
            return ProfileData(user: user, posts: posts, suggestions: suggestions)
        }
    }
}

enum ProfileError: Error {
    case incompleteData
}

What the code does, step by step

  1. Define a throwing task group whose child tasks each return a tuple (String, Any). The string key lets us identify which result belongs to which request.
  2. Add three child tasks with group.addTask. Each task performs a single network call and returns its labelled payload.
  3. Iterate with for try await over the group. This yields results as soon as each child finishes, preserving the order of completion, not the order of insertion.
  4. Cast and store the values into local optionals. Because the group is throwing, any error from a child bubbles up immediately, cancelling the remaining children.
  5. Validate completeness after the loop. If any piece is missing we throw a custom error — this protects against silent API changes.

Error handling and cancellation

Because we used withThrowingTaskGroup, the first error aborts the whole group. The remaining children receive a cancellation notice automatically; well‑behaved network layers (e.g., URLSession’s Task support) will stop the in‑flight request. If you need *all* results even when some fail, switch to a non‑throwing group and wrap each child in a Result:

let results = await withTaskGroup(of: Result<(String, Any), Error>.self) { group in
    group.addTask { .success(("user", try await api.fetchUser(id: userID))) }
    // … other tasks …
    var collected: [Result<(String, Any), Error]> = []
    for await result in group { collected.append(result) }
    return collected
}

Then you can decide per‑key whether to show a fallback UI or retry.

Testing the concurrency

Unit tests become straightforward because TaskGroup runs on the current executor. In a test you can inject a mock APIClient that returns immediately, then assert that loadProfile completes in roughly the time of the slowest stub — not the sum of all three. Use XCTestExpectation or the new async test APIs to verify cancellation paths as well.

When to reach for TaskGroup

  • Multiple independent async operations that share a common deadline.
  • You want automatic cancellation propagation without manual Task.cancel() calls.
  • You need to collect heterogeneous results in a type‑safe way.

Avoid it for fire‑and‑forget work that doesn’t need a collective result — a plain Task { … } is simpler there.

Wrapping up

Structured concurrency turned what used to be a tangle of callbacks and manual state machines into a few lines of readable Swift. TaskGroup is the workhorse for parallel data fetching, and once you internalise its cancellation semantics you’ll find yourself reaching for it in almost every view‑model that aggregates remote data. Give it a try on the next screen that loads three or more endpoints — you’ll feel the latency drop instantly.