Introduction

When I first started working on iOS apps that needed to pull data from multiple remote endpoints, I reached for a simple `URLSession.dataTask` inside a `for` loop. The app would fetch one resource, wait for it to finish, then move to the next. As the number of requests grew, the UI would stall, and the user experience suffered. The solution I now rely on in every project is Swift’s `TaskGroup`. It lets you run many asynchronous tasks in parallel while still keeping control over concurrency limits and error handling. In this article I’ll walk you through a practical example, explain the reasoning behind the approach, and point out a few pitfalls to avoid.

The Problem with Serial Fetching

Imagine an app that displays a user’s profile, a list of recent posts, and a set of suggested images—all loaded from different URLs. Using a sequential approach looks simple:

func fetchAll(urls: [URL]) async -> [Data] {
    var results: [Data] = []
    for url in urls {
        let (data, _) = try await URLSession.shared.data(from: url)
        results.append(data)
    }
    return results
}

Each request blocks the next, so the total time is the sum of individual network latencies. On a slow connection the UI thread can become unresponsive, and the user sees a loading spinner for an unnecessarily long period. Moreover, there is no built‑in way to limit the number of simultaneous downloads or to stop the whole operation if one request fails.

Leveraging TaskGroup for Parallel Work

Swift’s `TaskGroup` solves both issues. You can launch many tasks, each of which can await its own network call, and you can still enforce a concurrency limit using the `maxThroughput` parameter. Errors are propagated back to the caller via a dedicated `TaskGroup` API, making it easy to decide whether to abort the whole batch or just skip the failing task.

The pattern I use in production looks like this:

  • Create a `TaskGroup` with a desired throughput (e.g., three simultaneous downloads).
  • Iterate over the URLs and call `taskGroup.addTask { … }` for each one.
  • Inside the closure, perform the network request and, if successful, send the result back via `taskGroup.next()`.
  • Collect results in a local array, handling `nil` for cancelled or errored tasks.

The beauty is that the code stays linear and readable while the execution is truly concurrent.

Production‑Ready Example

Below is a complete, comment‑laden function that fetches multiple images, resizes them in a lightweight way, and returns an array of `UIImage`. It also respects a concurrency limit of three tasks and gracefully skips any download that errors out.

import Foundation
import UIKit

func fetchAndResizeImages(urls: [URL], maxConcurrent: Int = 3) async -> [UIImage] {
    // 1. Prepare an empty array to hold our final images.
    var images: [UIImage] = []

    // 2. Create a TaskGroup with a limited throughput.
    //    This prevents us from spawning hundreds of simultaneous network tasks.
    await withTaskGroup(of: Data?.self) { group in
        // 3. Seed the group with one task per URL.
        for url in urls {
            group.addTask {
                // Perform the network fetch. If it fails, return nil.
                do {
                    let (data, _) = try await URLSession.shared.data(from: url)
                    return data
                } catch {
                    // Log the error if you need debugging info.
                    print("Failed to download \(url): \(error)")
                    return nil
                }
            }
        }

        // 4. Iterate over results as they complete.
        for case let data? in group {
            // Guard against a nil result (error case).
            guard let data = data else { continue }

            // 5. Perform a quick resize to reduce memory footprint.
            //    Using UIImage(data:) is safe because we already validated the data.
            if let image = UIImage(data: data) {
                // Downsample to a maximum dimension of 300pt while keeping aspect ratio.
                let targetSize = CGSize(width: 300, height: 300)
                let renderer = UIGraphicsRenderer(size: targetSize)
                let scaledImage = renderer.image { _ in
                    image.draw(in: CGRect(origin: .zero, size: targetSize))
                }
                images.append(scaledImage)
            }
        }
    }

    return images
}

Notice how the function is expressed in a single `await withTaskGroup` block. The `for case let data? in group` loop consumes results as soon as each task finishes, which keeps memory usage low and avoids building a huge intermediate array.

Why this pattern works. TaskGroup isolates each network call so they can run truly in parallel, yet the `maxConcurrent` parameter ensures we never overload the system or the remote API. By returning `Data?` we can propagate errors without aborting the whole batch, giving us fine‑grained control over failure handling.

Best Practices and Tips

  • Set a sensible concurrency limit. Three to five concurrent downloads are usually enough for mobile networks. Adjust based on the typical payload size and the app’s UI responsiveness.
  • Prefer structured concurrency. Always pair `TaskGroup` with `await withTaskGroup`. This ensures proper cancellation propagation when the surrounding async context is cancelled.
  • Cache results early. If you have many URLs, consider deduplicating or caching previously fetched data to avoid redundant network traffic.
  • Use structured error handling. The `Data?` return type is convenient, but for more complex error scenarios you can introduce a custom `Result` type or an `enum` to differentiate between network failures and processing errors.

When Not to Reach for TaskGroup

TaskGroup shines for independent, I/O‑bound operations. If your work is CPU‑intensive or tightly coupled (for example, a series of steps that depend on the result of the previous one), a simple `for` loop or a chain of `async let` statements may be clearer. Additionally, if you need to preserve the exact order of results, you can either store tuples with an index or switch to a simpler sequential approach.

Summary

Managing multiple network requests in iOS used to be a matter of looping over URLs and awaiting each one in turn. That approach is easy to write but costly in terms of time and user experience. Swift’s `TaskGroup` gives us a concise, safe, and efficient way to run those requests in parallel while still limiting concurrency and handling errors gracefully. By integrating a `TaskGroup` into my daily workflow, I’ve reduced loading times, improved UI fluidity, and kept my code readable and maintainable. Give it a try on your next batch of remote data and you’ll see the difference for yourself.