With the release of Swift 5.5, Apple introduced a completely new Structured Concurrency model. Based on async/await, this model changes the way we write asynchronous code forever, waving goodbye to the traditional callback-based “Pyramid of Doom”.

Why Structured Concurrency?

Before structured concurrency, managing multiple asynchronous actions usually looked like this:

func fetchUserData(completion: @escaping (User?, Error?) -> Void) {
    URLSession.shared.dataTask(with: userURL) { data, response, error in
        guard let data = data else {
            completion(nil, error)
            return
        }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(user, nil)
        } catch {
            completion(nil, error)
        }
    }.resume()
}

This design had major pain points:

  1. Confusing Control Flow: It’s easy to miss calling the completion block, hanging the application.
  2. Clunky Error Handling: We couldn’t use standard Swift throw mechanisms, relying on double optionals or Result instead.
  3. No Automatic Cancellation: If a user leaves the screen, the underlying network call keeps running, wasting battery and bandwidth.

Async/Await in Action

async/await allows us to write async code as if it were synchronous:

func fetchUserData() async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: userURL)
    return try JSONDecoder().decode(User.self, from: data)
}

Here, await explicitly marks a suspension point. When execution reaches this point, the current thread is freed to perform other tasks, resuming only after the network request finishes.

Task and Structured Scoping

The core of structured concurrency is that child tasks are bound to the lifetime of their parent task. This is achieved via Task and TaskGroup.

1. Parallel Bindings (Async Let)

When you need to perform multiple independent tasks in parallel and wait for all of them, use async let:

func fetchDashboardData() async throws -> Dashboard {
    async let user = fetchUserData()
    async let stats = fetchAppStats()
    
    // Both execute in parallel; they await here together
    return try await Dashboard(user: user, stats: stats)
}

2. Task Groups

For dynamic numbers of concurrent tasks, use withThrowingTaskGroup:

func fetchAllImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                return UIImage(data: data) ?? UIImage()
            }
        }
        
        var images = [UIImage]()
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Automatic Cancellation Propagation

One of the greatest benefits of structured concurrency is automatic cancellation. If a parent task is cancelled, all child tasks (including those in a TaskGroup) automatically receive cancellation signals and stop execution:

group.addTask {
    try Task.checkCancellation() // Periodically check cancellation
    let (data, _) = try await URLSession.shared.data(from: url)
    return UIImage(data: data) ?? UIImage()
}

Summary

Swift Structured Concurrency makes code cleaner and elevates memory safety to the compiler level. Shifting to async/await minimizes async bugs and improves codebase readability in iOS apps.