Skip to content

How to use TaskGroup to perform concurrent Tasks in Swift | Modern Swift Concurrency #6

When you have a collection of items to process concurrently — a list of image URLs, a batch of API requests, a set of database records — async let won't work because you can't write one binding per item when the count is only known at runtime. TaskGroup is the structured concurrency solution for this: it lets you add child tasks dynamically in a loop, then collect all results as they complete.

What You'll Learn

  • How withThrowingTaskGroup(of:) creates a scoped group where you can add an arbitrary number of concurrent child tasks
  • How to collect results from the group using an async for loop that processes each result as it arrives, regardless of order
  • Why TaskGroup preserves structured concurrency guarantees (automatic cancellation, no leaks) even for dynamically-sized workloads

Mental Model

Imagine a race with a variable number of runners — you don't know how many until race day. With async let, you'd have to write one finishing-line observer per runner, decided before the race. With TaskGroup, you have a single results booth: runners register when they cross the finish line, and you process each one as they arrive. You don't care which runner finishes first; you just collect results until the group is done.

The finish-line booth is the for try await image in group loop. It doesn't poll or block — it suspends until the next result is ready, then processes it. After the last child task finishes, the loop exits and the group's closure returns. The entire thing is still structured: if you cancel the parent task, all running child tasks are cancelled automatically.

Detailed Explanation

withThrowingTaskGroup(of: ChildTaskResult.self) is the entry point. The of parameter specifies the return type each child task will produce. All child tasks in the group must return the same type — in this sample, UIImage?. The closure receives a group parameter (of type ThrowingTaskGroup) that you use to add child tasks and collect results.

Inside the closure, group.addTask { } spawns a concurrent child task. You can call addTask in a loop to create as many concurrent tasks as needed. All added tasks begin executing immediately and run in parallel. The loop adds all tasks before any results are collected — by the time the for try await loop starts, all downloads are already in-flight.

for try await image in group is an asynchronous sequence iteration. This loop suspends until any child task completes, processes its result, then waits for the next one. Crucially, results arrive in completion order, not in the order tasks were added. The first image to finish downloading (regardless of URL order) is the first one appended to the array. If your downstream code requires input-order results, you need to tag each child task result with its index and sort after collection.

images.reserveCapacity(urlStrings.count) pre-allocates storage for the final array. This is a minor performance optimization — without it, Swift's Array would reallocate storage multiple times as elements are appended. It is only safe when you know the maximum count upfront, which you do here.

The try? in group.addTask { try? await self.fetchImage(urlString: urlString) } converts a throwing fetch into UIImage?. This means a single failed URL doesn't throw and cancel the whole group — it just produces a nil that is filtered out later. If you want one failure to abort all remaining work, use throw inside the child task and withThrowingTaskGroup with a do-catch around the for try await loop.

The fetchImagesWithAsyncLet method at the top of TaskGroupBootcampDataManager is present for direct comparison. It hardcodes exactly four async let bindings. The fetchImagesWithTaskGroup method accepts an array of any length. This is the key architectural difference: async let for compile-time-fixed parallelism, TaskGroup for runtime-dynamic parallelism.

Code Structure

TaskGroupBootcamp.swift separates networking into TaskGroupBootcampDataManager which has both the async let and task group implementations side by side. TaskGroupBootcampViewModel calls the task group version and exposes images via @Published. TaskGroupBootcamp renders results in a two-column grid using LazyVGrid. The .task modifier ensures the load is tied to view lifecycle.

Complete Code

TaskGroupBootcamp.swift

swift
import SwiftUI

class TaskGroupBootcampDataManager {
    
    // async let version: compile-time-fixed at exactly 4 images — inflexible but expressive for known-count parallelism
    func fetchImagesWithAsyncLet() async throws -> [UIImage] {
        async let fetchImage1 = fetchImage(urlString: "https://picsum.photos/300") // starts immediately
        async let fetchImage2 = fetchImage(urlString: "https://picsum.photos/300") // starts immediately, concurrent with image1
        async let fetchImage3 = fetchImage(urlString: "https://picsum.photos/300")
        async let fetchImage4 = fetchImage(urlString: "https://picsum.photos/300")
        
        let (image1, image2, image3, image4) = await (try fetchImage1, try fetchImage2, try fetchImage3, try fetchImage4) // waits for all four simultaneously
        return [image1, image2, image3, image4]
    }
    
    // TaskGroup version: accepts any number of URLs — the count is only known at runtime
    func fetchImagesWithTaskGroup() async throws -> [UIImage] {
        let urlStrings = [
            "https://picsum.photos/300",
            "https://picsum.photos/300",
            "https://picsum.photos/300",
            "https://picsum.photos/300",
            "https://picsum.photos/300",
        ]
        return try await withThrowingTaskGroup(of: UIImage?.self) { group in // UIImage? because a failed fetch returns nil, not throws
            var images: [UIImage] = []
            images.reserveCapacity(urlStrings.count) // pre-allocate to avoid repeated array reallocation as results arrive
            
            for urlString in urlStrings {
                group.addTask { // each addTask spawns a concurrent child task; all five start before any results are collected
                    try? await self.fetchImage(urlString: urlString) // try? means a single URL failure produces nil instead of cancelling the group
                }
            }
            
            for try await image in group { // async for-loop: suspends until any child task finishes, in completion order (not input order)
                if let image = image {
                    images.append(image) // nil results (failed fetches) are silently skipped
                }
            }
            
            return images // returned after ALL child tasks have finished; the group's structured scope ensures this
        }
    }
    
    private func fetchImage(urlString: String) async throws -> UIImage {
        guard let url = URL(string: urlString) else {
            throw URLError(.badURL) // fail fast with a descriptive error for invalid URL strings
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
            if let image = UIImage(data: data) {
                return image
            } else {
                throw URLError(.badURL) // data was received but couldn't be decoded as an image
            }
        } catch {
            throw error
        }
    }
}

class TaskGroupBootcampViewModel: ObservableObject {
    
    @Published var images: [UIImage] = []
    let manager = TaskGroupBootcampDataManager()

    func getImages() async {
        if let images = try? await manager.fetchImagesWithTaskGroup() { // try? means errors are silently ignored; consider logging in production
            self.images.append(contentsOf: images) // safe: getImages is called from @MainActor context via .task
        }
    }
}

struct TaskGroupBootcamp: View {
    
    @StateObject private var viewModel = TaskGroupBootcampViewModel()
    let columns = [GridItem(.flexible()), GridItem(.flexible())]

    var body: some View {
        NavigationView {
            ScrollView {
                LazyVGrid(columns: columns) {
                    ForEach(viewModel.images, id: \.self) { image in
                        Image(uiImage: image)
                            .resizable()
                            .scaledToFit()
                            .frame(height: 150)
                    }
                }
            }
            .navigationTitle("Task Group 🥳")
            .task { // .task ties the load to view lifetime; navigating away cancels all in-flight child tasks automatically
                await viewModel.getImages()
            }
        }
    }
}

struct TaskGroupBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        TaskGroupBootcamp()
    }
}

Code Walkthrough

  1. withThrowingTaskGroup(of: UIImage?.self) — The of type parameter declares what each child task returns. All child tasks must return the same type. Using UIImage? (optional) allows individual tasks to fail without throwing — a nil result is filtered out in the collection loop. If you used UIImage (non-optional), a failed fetch would need to throw, which would trigger the catch block and cancel remaining tasks.

  2. The addTask loop — All five addTask calls complete before the for try await loop starts. This means all five network requests are in-flight simultaneously. The loop doesn't block addTask; addTask is a synchronous call that schedules work. This is the critical difference from a sequential for loop with await inside: a sequential loop would download one image, wait, download the next, wait, etc.

  3. for try await image in group — This loop is how you "drain" the group. It yields a value each time any child task completes. If image 3 finishes first, image 3 is yielded first, regardless of its position in urlStrings. The loop continues until all child tasks have finished. After the loop, return images exits the closure with the fully collected array.

  4. Cancellation propagation — If the parent task (from .task) is cancelled while fetchImagesWithTaskGroup is running, the withThrowingTaskGroup closure automatically cancels all in-flight child tasks. Each URLSession.data(from:) call receives a cancellation signal and throws CancellationError. The try? in group.addTask would convert those CancellationErrors to nil, but since the parent task is cancelled, the group exits anyway. Structured concurrency ensures no orphaned downloads.

  5. Comparison with fetchImagesWithAsyncLet — Both functions produce the same result. fetchImagesWithAsyncLet is rigid — it always fetches exactly 4 images and the URLs are hardcoded. fetchImagesWithTaskGroup accepts any array of URL strings. In a real app, those URLs might come from an API response, a database query, or user-selected items — a count you can't know until runtime.

Common Mistakes

Mistake: Mutating a variable declared outside the group from inside group.addTask Inside a task group's child tasks, you cannot safely access a variable declared outside the withThrowingTaskGroup closure from multiple child tasks simultaneously — that would be a data race. The correct pattern is what the sample shows: collect results using the for try await loop (which runs sequentially on the parent task) and mutate images there, not inside addTask.

Mistake: Assuming for try await yields results in the same order tasks were added Task group results arrive in completion order. A 1KB image will arrive before a 10MB image, regardless of which URL was added first. If you need to display images in a specific order (e.g., matching the original URL array), tag each result with its index in addTask, sort the collected results by index after the loop, and then map to the final values.

Mistake: Adding unlimited child tasks for large datasets Spawning thousands of child tasks simultaneously for a large URL array will saturate network connections and overwhelm URLSession's connection pool. In practice, cap concurrent tasks using group.addTask with a throttle: add tasks in batches, or limit with a semaphore-equivalent counter, or use group.waitForAll() periodically. The cooperative thread pool has internal limits, but connection-level resources do not.

Key Takeaways

  • withThrowingTaskGroup is structured concurrency for runtime-dynamic parallelism — the number of concurrent operations can come from any collection, unlike async let which requires compile-time-fixed count
  • Results from a task group arrive in completion order, not input order — tag results with their index if ordering matters
  • All child tasks are automatically cancelled when the parent task is cancelled, and the group's closure does not return until all child tasks finish — structured concurrency prevents both leaks and premature exits

Last updated: June 27, 2026

Released under the MIT License.