All articles
iOS7 min read

Master Structured Concurrency in 20 Minutes or Keep Writing Buggy Async Code

Everything you need to know about structured concurrency: automatic cancellation, task hierarchies, and parallel execution without the…

K
Karan Pal
Author
Mastering Concurrency
Mastering Concurrency

🔥 The Async Problem

Let me show you some code I wrote a couple years back. I needed to fetch data from multiple APIs and combine the results. Here’s what that looked like:

func fetchDashboardData(completion: @escaping (DashboardData?, Error?) -> Void) {
    var userProfile: UserProfile?
    var recentActivity: [Activity]?
    var notifications: [Notification]?
    var fetchError: Error?
    
    let group = DispatchGroup()
    
    // Fetch user profile
    group.enter()
    networkManager.getProfile { profile, error in
        userProfile = profile
        if let error = error { fetchError = error }
        group.leave()
    }
    
    // Fetch recent activity
    group.enter()
    networkManager.getActivity { activity, error in
        recentActivity = activity
        if let error = error { fetchError = error }
        group.leave()
    }
    
    // Fetch notifications
    group.enter()
    networkManager.getNotifications { notifs, error in
        notifications = notifs
        if let error = error { fetchError = error }
        group.leave()
    }
    
    group.notify(queue: .main) {
        if let error = fetchError {
            completion(nil, error)
            return
        }
        
        guard let profile = userProfile,
              let activity = recentActivity,
              let notifs = notifications else {
            completion(nil, NSError(domain: "Incomplete", code: -1))
            return
        }
        
        let dashboard = DashboardData(
            profile: profile,
            activities: activity,
            notifications: notifs
        )
        completion(dashboard, nil)
    }
}

Look at that mess.

Multiple completion handlers. Manual DispatchGroup management with enter() and leave() calls that you can easily mess up. Optional unwrapping everywhere. Error handling that's half-baked at best. And if one request fails, the others keep running anyway—wasting battery and bandwidth.

Oh, and what if the user navigates away? Those callbacks still fire. Memory leaks waiting to happen.

This is what async code looked like before structured concurrency. And yeah, a lot of codebases still look like this.

There’s gotta be a better way, right?

🎯 Structured Concurrency 101

Structured concurrency is Apple’s answer to async chaos. The core idea? Tasks have lifetimes and parent-child relationships.

Think of it like how regular code works. When you call a function, it runs, and when it finishes, you get back to where you were. Nested function calls create a hierarchy. If the outer function exits, inner functions can’t keep running.

Structured concurrency applies that same concept to async operations.

The rules:

  1. Tasks have parents — When you create a task, it’s a child of whatever task created it
  2. Parents wait for children — A parent task doesn’t finish until all its children finish
  3. Cancellation cascades — Cancel a parent, all children get cancelled automatically
  4. Errors propagate up — Child tasks can throw errors to their parents

These aren’t just nice ideas — Swift enforces them. You literally can’t create orphan tasks that run forever in the background (well, you can with Task.detached, but that's explicitly opting out).

Here’s what that dashboard code looks like with structured concurrency:

func fetchDashboardData() async throws -> DashboardData {
    async let userProfile = networkManager.getProfile()
    async let recentActivity = networkManager.getActivity()
    async let notifications = networkManager.getNotifications()
    
    let profile = try await userProfile
    let activity = try await recentActivity
    let notifs = try await notifications
    
    return DashboardData(
        profile: profile,
        activities: activity,
        notifications: notifs
    )
}

That’s it. Same functionality. No completion handlers, no DispatchGroup, no manual error juggling. And if any request fails, the others automatically cancel. If the task gets cancelled, all three requests stop.

Magic? Nah, just structured concurrency.

📺 I’m working on a video walking through real async patterns with TaskGroups in an actual app. Check it out on Swift Pal: _https://youtube.com/@swift-pal_

⚡ async/await Crash Course

Before we dive into TaskGroups, quick refresher on async/await if you’re rusty.

The basics:

// Old way - completion handler
func downloadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data, let image = UIImage(data: data) else {
            completion(nil)
            return
        }
        completion(image)
    }.resume()
}

// New way - async/await
func downloadImage(from url: URL) async throws -> UIImage {
    let (imageData, _) = try await URLSession.shared.data(from: url)
    guard let image = UIImage(data: imageData) else {
        throw ImageError.invalidData
    }
    return image
}

Key differences:

Calling async functions:

You can only call async functions from:

// From a regular function - wrap in Task
func regularFunction() {
    Task {
        let image = try await downloadImage(from: someURL)
        // Do something with image
    }
}

// From async function - just await
func anotherAsyncFunction() async {
    let image = try await downloadImage(from: someURL)
    // Use image
}

// In SwiftUI
struct ImageView: View {
    @State private var loadedImage: UIImage?
    
    var body: some View {
        Image(uiImage: loadedImage ?? UIImage())
            .task {
                loadedImage = try? await downloadImage(from: someURL)
            }
    }
}

Cool? Cool. Now let’s talk TaskGroups.

👥 TaskGroups: Your New Best Friend

TaskGroups let you run multiple async operations in parallel and collect their results.

Remember that dashboard example with async let? That works great for a fixed number of tasks you know at compile time. But what if you have a dynamic list?

// What if you have an array of URLs?
let imageURLs: [URL] = [url1, url2, url3, url4, url5]

// Can't do this:
// async let images = imageURLs.map { try await downloadImage(from: $0) }
// That would download sequentially!

This is where TaskGroups shine.

Basic TaskGroup:

func downloadMultipleImages(from urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { taskGroup in
        // Add a task for each URL
        for imageURL in urls {
            taskGroup.addTask {
                try await self.downloadImage(from: imageURL)
            }
        }
        
        // Collect results
        var downloadedImages: [UIImage] = []
        for try await image in taskGroup {
            downloadedImages.append(image)
        }
        
        return downloadedImages
    }
}

What’s happening here:

  1. withThrowingTaskGroup creates a group that can handle errors
  2. of: UIImage.self tells Swift what type each task returns
  3. taskGroup.addTask { } creates a new parallel task
  4. for try await image in taskGroup collects results as they complete
  5. When the closure ends, Swift waits for all tasks to finish

The tasks run in parallel. If you have 5 URLs, all 5 download at the same time (subject to system limits).

Non-throwing version:

If your tasks don’t throw errors, use withTaskGroup:

func loadUserAvatars(for userIDs: [String]) async -> [UIImage] {
    await withTaskGroup(of: UIImage?.self) { taskGroup in
        for userId in userIDs {
            taskGroup.addTask {
                try? await self.fetchAvatar(for: userId)
            }
        }
        
        var avatars: [UIImage] = []
        for await avatar in taskGroup {
            if let avatar = avatar {
                avatars.append(avatar)
            }
        }
        
        return avatars
    }
}

Notice of: UIImage?.self because we're swallowing errors with try?, so tasks return optionals.

🔄 Common Patterns

Let’s look at patterns you’ll actually use.

Pattern 1: Collect all results

func fetchAllProducts() async throws -> [Product] {
    let productIDs = ["PROD-001", "PROD-002", "PROD-003", "PROD-004"]
    
    return try await withThrowingTaskGroup(of: Product.self) { group in
        for productID in productIDs {
            group.addTask {
                try await self.apiClient.fetchProduct(id: productID)
            }
        }
        
        var allProducts: [Product] = []
        for try await product in group {
            allProducts.append(product)
        }
        
        return allProducts
    }
}

Pattern 2: Process results as they arrive

Don’t wait for everything — handle results as soon as they’re ready:

func processImagesAsTheyLoad(urls: [URL]) async {
    await withTaskGroup(of: (URL, UIImage?).self) { group in
        for imageURL in urls {
            group.addTask {
                let img = try? await self.downloadImage(from: imageURL)
                return (imageURL, img)
            }
        }
        
        for await (url, image) in group {
            if let image = image {
                print("✅ Loaded: \(url.lastPathComponent)")
                await self.displayImage(image)
            } else {
                print("❌ Failed: \(url.lastPathComponent)")
            }
        }
    }
}

Results come back in completion order, not submission order. First one done gets processed first.

Pattern 3: Limit concurrent tasks

Don’t want to hammer the server with 100 simultaneous requests? Batch them:

func downloadImagesBatched(urls: [URL], batchSize: Int = 5) async throws -> [UIImage] {
    var allImages: [UIImage] = []
    
    // Process in chunks
    for batchStart in stride(from: 0, to: urls.count, by: batchSize) {
        let batchEnd = min(batchStart + batchSize, urls.count)
        let batchURLs = Array(urls[batchStart..<batchEnd])
        
        let batchImages = try await withThrowingTaskGroup(of: UIImage.self) { group in
            for url in batchURLs {
                group.addTask {
                    try await self.downloadImage(from: url)
                }
            }
            
            var images: [UIImage] = []
            for try await image in group {
                images.append(image)
            }
            return images
        }
        
        allImages.append(contentsOf: batchImages)
    }
    
    return allImages
}

Now you only have 5 downloads running at once.

Pattern 4: First success wins

Sometimes you just need the first successful result:

func fetchFromMirrors(mirrors: [URL]) async throws -> Data {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for mirrorURL in mirrors {
            group.addTask {
                try await self.download(from: mirrorURL)
            }
        }
        
        // Get first successful result
        guard let firstResult = try await group.next() else {
            throw NetworkError.allMirrorsFailed
        }
        
        // Cancel remaining tasks
        group.cancelAll()
        
        return firstResult
    }
}

group.next() gets the next completed task. Once you have it, cancelAll() stops the rest.

Pattern 5: Reduce/accumulate results

func calculateTotalFileSize(urls: [URL]) async throws -> Int64 {
    try await withThrowingTaskGroup(of: Int64.self) { group in
        for fileURL in urls {
            group.addTask {
                try await self.getFileSize(at: fileURL)
            }
        }
        
        var totalSize: Int64 = 0
        for try await size in group {
            totalSize += size
        }
        
        return totalSize
    }
}

🏗️ Cancellation & Error Handling

This is where structured concurrency really shines.

Automatic cancellation:

When a parent task gets cancelled, all child tasks cancel automatically.

let downloadTask = Task {
    try await downloadMultipleImages(from: urls)
}

// Later...
downloadTask.cancel()  // All child tasks in the TaskGroup cancel too

Inside your tasks, you can check for cancellation:

func downloadLargeFile(from url: URL) async throws -> Data {
    var downloadedData = Data()
    let chunkSize = 1024 * 1024  // 1MB chunks
    
    for chunkNumber in 0..<100 {
        // Check if cancelled
        if Task.isCancelled {
            print("Download cancelled, cleaning up...")
            throw CancellationError()
        }
        
        let chunk = try await fetchChunk(from: url, number: chunkNumber, size: chunkSize)
        downloadedData.append(chunk)
    }
    
    return downloadedData
}

Or use try Task.checkCancellation() which throws if cancelled:

func processItems(_ items: [String]) async throws {
    for item in items {
        try Task.checkCancellation()  // Throws if cancelled
        await processItem(item)
    }
}

Error handling in TaskGroups:

If any task in a throwing TaskGroup throws an error, the whole group fails:

func fetchAllOrNothing(ids: [String]) async throws -> [DataItem] {
    try await withThrowingTaskGroup(of: DataItem.self) { group in
        for itemID in ids {
            group.addTask {
                try await self.fetchItem(id: itemID)
            }
        }
        
        var items: [DataItem] = []
        for try await item in group {
            items.append(item)
        }
        
        return items
    }
}
// If ANY fetch fails, the whole function throws
// Remaining tasks automatically cancel

Want to handle individual failures gracefully? Use optionals:

func fetchBestEffort(ids: [String]) async -> [DataItem] {
    await withTaskGroup(of: DataItem?.self) { group in
        for itemID in ids {
            group.addTask {
                try? await self.fetchItem(id: itemID)
            }
        }
        
        var successfulItems: [DataItem] = []
        for await item in group {
            if let item = item {
                successfulItems.append(item)
            }
        }
        
        return successfulItems
    }
}
// Returns whatever succeeded, ignores failures

Or collect errors separately:

struct FetchResult {
    let item: DataItem?
    let error: Error?
}

func fetchWithErrors(ids: [String]) async -> [FetchResult] {
    await withTaskGroup(of: FetchResult.self) { group in
        for itemID in ids {
            group.addTask {
                do {
                    let item = try await self.fetchItem(id: itemID)
                    return FetchResult(item: item, error: nil)
                } catch {
                    return FetchResult(item: nil, error: error)
                }
            }
        }
        
        var results: [FetchResult] = []
        for await result in group {
            results.append(result)
        }
        
        return results
    }
}

Now you know exactly what failed and why.

🎬 Wrapping This Up

Alright, so if you made it through all that, you now know structured concurrency better than most iOS devs out there.

Quick recap:

The problem: Old async patterns (completion handlers, DispatchGroup) were messy, error-prone, and leaked memory.

The solution: Structured concurrency gives tasks lifetimes, parent-child relationships, and automatic cancellation.

async/await: Modern syntax for async operations. No more completion handler hell.

TaskGroups: Run multiple async operations in parallel. Collect results. Handle errors. All with clean, readable code.

Cancellation: Happens automatically when parent tasks cancel. Check with Task.isCancelled or Task.checkCancellation().

Error handling: Either fail-fast (throwing TaskGroup) or handle errors individually (non-throwing with optionals).

The key insight? Concurrency should be structured just like regular code. Tasks have scopes. They don’t leak. Cancellation cascades. Resources clean up automatically.

Start using TaskGroups in your next feature that needs parallel operations. Loading multiple images? TaskGroup. Batch API requests? TaskGroup. Processing files? TaskGroup.

You’ll wonder how you ever lived without them.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading