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…

🔥 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:
- Tasks have parents — When you create a task, it’s a child of whatever task created it
- Parents wait for children — A parent task doesn’t finish until all its children finish
- Cancellation cascades — Cancel a parent, all children get cancelled automatically
- 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:
asynckeyword marks function as asynchronousawaitkeyword marks where the function might suspendthrowsfor errors (no more Result types or optional errors)- No completion handlers — just return values
- Errors propagate with regular
throw
Calling async functions:
You can only call async functions from:
- Other
asyncfunctions - Inside a
Task { }block - SwiftUI
.taskmodifier - Test functions marked
async
// 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:
withThrowingTaskGroupcreates a group that can handle errorsof: UIImage.selftells Swift what type each task returnstaskGroup.addTask { }creates a new parallel taskfor try await image in taskGroupcollects results as they complete- 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 tooInside 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 cancelWant 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 failuresOr 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! 🚀
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
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read