Swift TaskGroup for Parallel Image Loading: A Production‑Ready Pattern
The Problem: Loading a Gallery Without Blocking the Main Thread
When you need to display a grid of images fetched from a remote API, the naïve approach is to fire off a separate URLSession request for each URL and update the UI as each one finishes. In practice this creates a flood of tasks, makes cancellation tricky, and often leads to race conditions where images appear in the wrong order.
Enter TaskGroup – Structured Concurrency for Parallel Work
Swift’s TaskGroup gives you a lightweight way to run a dynamic number of child tasks, collect their results, and automatically propagate cancellation. Because the group is tied to the parent task’s lifecycle, you get deterministic cleanup without extra boilerplate.
Production‑Ready Snippet
import Foundation
import UIKit
/// Fetches a collection of images concurrently, returning them in the same order as the input URLs.
/// - Parameters:
/// - urls: The remote image URLs to download.
/// - session: The URLSession to use (default: shared). Allows injection for testing.
/// - Returns: An array of UIImage? matching the input order; nil for any failed download.
func fetchImages(urls: [URL], session: URLSession = .shared) async -> [UIImage?] {
// The result array is pre‑allocated so we can write each image at its original index.
var images: [UIImage?] = Array(repeating: nil, count: urls.count)
// TaskGroup automatically cancels all children if the parent task is cancelled.
await withTaskGroup(of: (Int, UIImage?).self) { group in
for (index, url) in urls.enumerated() {
group.addTask { @Sendable in
do {
let (data, _) = try await session.data(from: url)
if let image = UIImage(data: data) {
return (index, image)
}
} catch {
// Swallow errors – we’ll return nil for this slot.
}
return (index, nil)
}
}
// Collect results as they finish. Order of completion is irrelevant.
for await (index, image) in group {
images[index] = image
}
}
return images
}
Why This Works
- Deterministic ordering – By capturing the original index, we write each result into a pre‑sized array, guaranteeing the output matches the input order regardless of which request finishes first.
- Automatic cancellation – If the view controller dismisses while the group is running, the parent task is cancelled and
TaskGrouppropagates that cancellation to every child, freeing network resources instantly. - Structured error handling – Errors are caught inside each child task; the group never throws, so the caller receives a clean array with
nilplaceholders for failures. - Testability – The
sessionparameter defaults to.sharedbut can be swapped for a mockedURLSessionin unit tests.
Real‑World Integration
In a UICollectionViewController you can kick off the fetch in viewDidLoad and reload the collection view once the array returns:
override func viewDidLoad() {
super.viewDidLoad()
let urls = galleryItems.map(\.imageURL)
Task { @MainActor in
let images = await fetchImages(urls: urls)
self.galleryImages = images
self.collectionView.reloadData()
}
}
The @MainActor attribute ensures the UI update runs on the main thread, while the heavy lifting stays off it.
Gotchas to Watch
If you need to limit the number of simultaneous connections (e.g., to respect a server’s rate limit), wrap the
group.addTaskloop in a semaphore or use a customTaskGroupthat batches work. The built‑in group runs everything concurrently.
Also remember that UIImage(data:) can be slow for large payloads; consider down‑sampling on a background queue before handing the image to the UI.
Wrap Up
Structured concurrency with TaskGroup turns a messy nest of callbacks into a clear, cancellable, and testable data‑fetching pipeline. I use this pattern whenever I need to pull multiple independent resources — images, JSON blobs, or even local file reads — and it has saved me countless hours debugging race conditions. Give it a try next time you face a “fetch‑many” scenario.