SwiftConcurrencyasync/awaitiOSSwiftUI

Structured concurrency in practice: async let, TaskGroup, cancellation and actors

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 let line, not on the await. By the time you reach the return, all three requests have been running for a while.
  • If one throws, the rest are cancelled. The try await on the return propagates the first error and automatically cancels the other two child tasks. No orphan requests left behind.
  • async let is for a fixed, known number of tasks. If the count is dynamic, you need a TaskGroup (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 let sequential by accident. If you let x = await api.a() before declaring the next async let, you’ve already lost the parallelism. Declare all the async lets together and await at the end.
  • Forgetting that TaskGroup reorders. If order matters, reassociate by index (section 2). It’s the group’s most silent bug.
  • Creating loose Task { } instead of .task. A manual Task in onAppear doesn’t cancel when the view disappears. Use .task / .task(id:) unless you have a strong reason not to.
  • Assuming the actor saves you from duplicate work. It saves you from corrupting state, not from reentrancy. To de-duplicate, cache the Task (section 5).
  • AsyncStream without onTermination. 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 parallelasync let + await at the end
Process a dynamic list of itemsTaskGroup (with a window and an index)
Cancel when the input changes.task(id:) + Task.sleep for debounce
Continue despite partial failureswithTaskGroup + Result inside the child
Share state without data racesactor (cache the Task, not just the value)
Consume events over timeAsyncStream 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.