In the previous post on modern Swift concurrency we covered the building blocks: async/await, Task, actor, @MainActor and Sendable. We saw what structured concurrency is and what async let and TaskGroup look like in their simplest form.
This post is the practical follow-up. Instead of explaining the syntax again, we’ll look at where structured concurrency actually gets used in a real app: loading a screen from several sources in parallel, processing N items in a batch, cancelling a search while the user keeps typing, handling partial failures, sharing state with an actor, and turning callback APIs into an AsyncStream. Every example is ready to adapt to your own code.
1. Loading a screen from several sources — async let
This is the single most common case. A detail screen needs three independent things: the user’s profile, their posts and their stats. They come from three different endpoints and none depends on the others. Loading them serially wastes time:
// ❌ Serial: 3 round-trips, one after another. If each takes 400 ms → 1.2 s.
func loadScreen(id: User.ID) async throws -> ProfileScreen {
let profile = try await api.profile(id)
let posts = try await api.posts(of: id)
let stats = try await api.stats(of: id)
return ProfileScreen(profile: profile, posts: posts, stats: stats)
}
With async let all three start at once and the total is the slowest one, not the sum:
// ✅ Parallel: all 3 run together. Total ≈ 400 ms.
func loadScreen(id: User.ID) async throws -> ProfileScreen {
async let profile = api.profile(id)
async let posts = api.posts(of: id)
async let stats = api.stats(of: id)
return try await ProfileScreen(
profile: profile,
posts: posts,
stats: stats
)
}
Three details that matter:
- The work starts on the
async letline, not on theawait. By the time you reach thereturn, all three requests have been running for a while. - If one throws, the rest are cancelled. The
try awaiton thereturnpropagates the first error and automatically cancels the other two child tasks. No orphan requests left behind. async letis for a fixed, known number of tasks. If the count is dynamic, you need aTaskGroup(next section).
Wiring it to SwiftUI
In an @Observable model (or an ObservableObject) this lives behind a load() method that the view triggers with .task:
@MainActor
@Observable
final class ProfileModel {
private(set) var state: State = .loading
private let api: API
enum State {
case loading
case ready(ProfileScreen)
case error(String)
}
init(api: API) { self.api = api }
func load(id: User.ID) async {
state = .loading
do {
state = .ready(try await loadScreen(id: id))
} catch is CancellationError {
// The view went away: not a real error, show nothing.
} catch {
state = .error(error.localizedDescription)
}
}
private func loadScreen(id: User.ID) async throws -> ProfileScreen {
async let profile = api.profile(id)
async let posts = api.posts(of: id)
async let stats = api.stats(of: id)
return try await ProfileScreen(profile: profile, posts: posts, stats: stats)
}
}
struct ProfileView: View {
@State private var model = ProfileModel(api: .live)
let id: User.ID
var body: some View {
content
.task { await model.load(id: id) } // cancels itself when the view disappears
}
}
.task is the key piece: it creates a Task tied to the view’s lifecycle and cancels it automatically when the view disappears. That’s why we catch CancellationError without treating it as a failure.
2. Processing N items in a batch — TaskGroup
When the number of tasks is dynamic —downloading 40 images, generating thumbnails for a gallery, geocoding a list of addresses— async let won’t do. That’s where TaskGroup comes in.
The canonical example is a batch of downloads:
func downloadImages(_ urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask { try await self.downloadImage(url) }
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
It works, but it hides two traps that really bite in production.
Trap 1: order is not preserved
Results from a TaskGroup arrive in completion order, not submission order. If you send URLs [A, B, C] and B finishes first, your array ends up [B, …]. For a gallery where order matters, you have to reassociate each result with its index:
func downloadImages(_ urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
for (index, url) in urls.enumerated() {
group.addTask { (index, try await self.downloadImage(url)) }
}
// Collect into a dictionary keyed by index…
var byIndex: [Int: UIImage] = [:]
for try await (index, image) in group {
byIndex[index] = image
}
// …and rebuild the original order.
return urls.indices.compactMap { byIndex[$0] }
}
}
Trap 2: don’t launch 10,000 tasks at once
addTask in a loop over 10,000 URLs creates 10,000 child tasks immediately. The system won’t run 10,000 downloads in parallel, but it will reserve memory for all of them and saturate the connection pool. The fix is a sliding window: you start at most maxInFlight tasks and, every time one finishes, you feed in the next.
func downloadImages(_ urls: [URL], maxInFlight: Int = 6) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
var byIndex: [Int: UIImage] = [:]
var next = 0
// Start the first batch.
for _ in 0..<min(maxInFlight, urls.count) {
let i = next
group.addTask { (i, try await self.downloadImage(urls[i])) }
next += 1
}
// For every result that comes back, feed a new one in.
for try await (index, image) in group {
byIndex[index] = image
if next < urls.count {
let i = next
group.addTask { (i, try await self.downloadImage(urls[i])) }
next += 1
}
}
return urls.indices.compactMap { byIndex[$0] }
}
}
There are never more than maxInFlight active downloads, but the group stays full until the list is exhausted. This pattern is the difference between an app that respects the user’s network and one that chokes it.
3. Real cancellation — search with debounce
Cancellation is where structured concurrency shines, and the most real case is search-as-you-type. Without cancellation, typing “swift” fires five searches (“s”, “sw”, “swi”…) and the response for “sw” can arrive after the one for “swift” and overwrite the correct results.
The perfect tool in SwiftUI is .task(id:): when the id changes, it cancels the previous task and starts a new one. Combine it with a Task.sleep at the top for debouncing:
struct SearchView: View {
@State private var text = ""
@State private var results: [Result] = []
let api: API
var body: some View {
List(results) { Text($0.title) }
.searchable(text: $text)
.task(id: text) {
// 1. Debounce: wait 300 ms. If the user keeps typing,
// the id changes, this Task is cancelled and sleep throws CancellationError.
do { try await Task.sleep(for: .milliseconds(300)) }
catch { return }
guard !text.isEmpty else { results = []; return }
// 2. The search. If the id changes while it runs, it cancels itself.
do {
results = try await api.search(text)
} catch is CancellationError {
// Search replaced by a newer one: ignore.
} catch {
results = []
}
}
}
}
The elegant part: you never store Task references or call cancel() by hand. .task(id:) does it for you. Task.sleep throws CancellationError the moment the task is cancelled, so debounce and cancellation are the same mechanism.
Cooperative cancellation in your own code
Cancellation in Swift is cooperative: nobody force-kills your task. Your code has to check whether it was cancelled and stop. System APIs (URLSession, Task.sleep) already do this. In your own heavy loop, do it yourself:
func processBatch(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // throws CancellationError if cancelled
await process(item)
}
}
If you’d rather bail out without throwing, check the property:
for item in items {
if Task.isCancelled { break }
await process(item)
}
A TaskGroup propagates cancellation to all its children automatically, so if the view’s .task is cancelled, the whole download tree from section 2 stops on its own.
4. Partial errors in a group
By default, withThrowingTaskGroup has all-or-nothing semantics: the first child that throws cancels the rest and the error bubbles up. That’s correct for the dashboard in section 1 —if you can’t load the profile, there’s no screen to show.
But often you want the opposite: process everything you can and report what failed. Syncing 50 files where 3 fail shouldn’t throw away the other 47. The trick is to not let the error escape the child task: catch it inside and return it as a Result.
enum SyncResult {
case ok(File.ID)
case failed(File.ID, Error)
}
func sync(_ files: [File]) async -> [SyncResult] {
// Note: withTaskGroup (no "Throwing"), because the children no longer throw.
await withTaskGroup(of: SyncResult.self) { group in
for file in files {
group.addTask {
do {
try await self.upload(file)
return .ok(file.id)
} catch {
return .failed(file.id, error) // the error stays inside the child
}
}
}
var results: [SyncResult] = []
for await result in group {
results.append(result)
}
return results
}
}
Now upstream you can separate the wheat from the chaff and, say, retry only the ones that failed:
let results = await sync(files)
let failures = results.compactMap { r -> File.ID? in
if case .failed(let id, _) = r { return id } else { return nil }
}
if !failures.isEmpty {
// show "3 files couldn't be uploaded · Retry"
}
The mental rule: does one failure invalidate the whole result? If yes, let it propagate (withThrowingTaskGroup). If no, catch inside the child and return a Result (withTaskGroup).
5. Actors for caching and shared state
A cache is touched by many tasks at once, and that’s exactly what causes data races. An actor eliminates them by serializing access. A naive image cache looks like this:
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(_ url: URL) async throws -> UIImage {
if let cached = cache[url] {
return cached
}
let image = try await download(url)
cache[url] = image
return image
}
}
It works, but it has a subtle concurrency bug worth understanding: actor reentrancy.
The reentrancy gotcha
When an actor method hits await, it suspends and releases the actor so other calls can enter. In the code above, if two views request the same URL at nearly the same time, both pass the if let (nothing cached yet), both call await download(url)… and you download the same image twice. The actor protects you from corrupting the dictionary, but not from duplicating work, because the state can change across the await.
The fix is to cache the in-flight task, not just the result. That way the second call finds the Task already running and awaits it:
actor ImageCache {
private enum Entry {
case inProgress(Task<UIImage, Error>)
case ready(UIImage)
}
private var entries: [URL: Entry] = [:]
func image(_ url: URL) async throws -> UIImage {
// Already ready or downloading? Reuse it.
if let entry = entries[url] {
switch entry {
case .ready(let image):
return image
case .inProgress(let task):
return try await task.value // await the download in progress
}
}
// Nobody has asked for it: start the download and store it BEFORE await.
let task = Task { try await download(url) }
entries[url] = .inProgress(task)
do {
let image = try await task.value
entries[url] = .ready(image)
return image
} catch {
entries[url] = nil // failed: allow a later retry
throw error
}
}
}
Now N views requesting the same URL trigger one download and all get the same result. Storing the Task in the dictionary before the first await is what closes the reentrancy window. This pattern —de-duplicating in-flight requests— shows up in any serious cache, network client or resource loader.
6. AsyncStream for events
async/await solves “one value that arrives later”. But many things in iOS are “many values that arrive over time”: GPS locations, notifications, timer ticks, WebSocket messages. That’s what AsyncSequence is for, and the easiest way to build your own is AsyncStream.
Its best use is wrapping an old delegate or callback API and presenting it as a for await. A timer, for instance:
func ticks(every interval: TimeInterval) -> AsyncStream<Date> {
AsyncStream { continuation in
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
continuation.yield(Date()) // emit a value
}
continuation.onTermination = { _ in // cleanup on cancel/finish
timer.invalidate()
}
}
}
And you consume it like any async sequence, with cancellation for free:
.task {
for await instant in ticks(every: 1) {
clock = instant // stops on its own when the view disappears
}
}
onTermination is crucial: it fires when the consumer stops listening (the view went away, the task was cancelled) and it’s where you shut down the underlying resource. Without it, the timer would live forever.
Wrapping a delegate
The most frequent real case is adapting a classic delegate. With AsyncStream.makeStream you can cleanly separate the producer from the consumer:
final class Locations: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: AsyncStream<CLLocation>.Continuation?
func stream() -> AsyncStream<CLLocation> {
let (stream, continuation) = AsyncStream.makeStream(of: CLLocation.self)
self.continuation = continuation
manager.delegate = self
manager.startUpdatingLocation()
continuation.onTermination = { [weak manager] _ in
manager?.stopUpdatingLocation()
}
return stream
}
func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
for loc in locs { continuation?.yield(loc) }
}
}
A production detail: events can arrive faster than the consumer processes them. Control the buffer so you don’t accumulate memory without bound —in a location stream you almost always want only the latest:
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
// …
}
Common structured-concurrency mistakes
- Turning
async letsequential by accident. If youlet x = await api.a()before declaring the nextasync let, you’ve already lost the parallelism. Declare all theasync lets together andawaitat the end. - Forgetting that
TaskGroupreorders. If order matters, reassociate by index (section 2). It’s the group’s most silent bug. - Creating loose
Task { }instead of.task. A manualTaskinonAppeardoesn’t cancel when the view disappears. Use.task/.task(id:)unless you have a strong reason not to. - Assuming the
actorsaves you from duplicate work. It saves you from corrupting state, not from reentrancy. To de-duplicate, cache theTask(section 5). AsyncStreamwithoutonTermination. That’s a guaranteed resource leak: the timer, listener or socket stays alive.- Swallowing
CancellationError. It’s fine to ignore it in the UI (a replaced search), but don’t mistake it for a network failure or pop an error alert for it.
Summary
| You need… | Tool |
|---|---|
| Load N fixed sources in parallel | async let + await at the end |
| Process a dynamic list of items | TaskGroup (with a window and an index) |
| Cancel when the input changes | .task(id:) + Task.sleep for debounce |
| Continue despite partial failures | withTaskGroup + Result inside the child |
| Share state without data races | actor (cache the Task, not just the value) |
| Consume events over time | AsyncStream with onTermination |
Structured concurrency isn’t just “running things in parallel”. It’s a model where the task tree has a clear owner, cancellation propagates on its own, and errors leave no orphan work. Once you think in terms of “who is this task’s parent and when does it die”, half of concurrency bugs disappear before they’re written.
If you skipped the fundamentals, start with Modern concurrency in Swift and come back here for the real-world cases.