Structured Concurrency in Swift: Migrating Callbacks in an iOS App Without Breaking the UI
Migrating an iOS app from completion handlers to async/await sounds, on paper, like a mechanical change: swap each closure for an async function and move on. In practice, the part that goes wrong isn't the syntax, it's the lifecycle. A completion handler that fires after the view that triggered it has already disappeared is a known bug: worst case, something tries to update UI that no longer exists, and checking that weak self is still alive has been part of every iOS team's vocabulary for years. With Task, that same failure shows up in a different shape, and without a real grasp of structured concurrency, it's easier to introduce without noticing.
Why this isn't just syntactic sugar
The most commonly sold benefit of async/await is readability: asynchronous code that reads like synchronous code, no pyramids of nested closures. That part is real, but it isn't what changes app behavior. What changes behavior is structured cancellation: in GCD, cancelling an in-flight operation is the explicit responsibility of whoever started it, and if nobody implemented that, the operation just keeps running even after nobody cares about the result anymore. With Task, cancellation propagates through the hierarchy: if a parent Task is cancelled, its child tasks -the ones created inside a TaskGroup or with async let- receive that cancellation automatically. That's structured concurrency: the lifetime of an async task is bound to the lifetime of the task that created it, instead of floating free in the system.
Migrating without understanding this leads to something worse than sticking with completion handlers: code with the new syntax but the same unstructured behavior as before, with none of the actual benefits.
The simple case: wrapping an existing completion-handler API
Not everything gets rewritten at once. Most of the third-party SDKs we still rely on -analytics frameworks, a payments SDK, old URLSession wrappers written before Swift Concurrency existed- still expose completion handlers. The right way to bring them in isn't rewriting the SDK, it's wrapping the call once with withCheckedThrowingContinuation:
func fetchUserProfile(id: String) async throws -> UserProfile {
try await withCheckedThrowingContinuation { continuation in
legacyAPIClient.fetchProfile(userId: id) { result in
switch result {
case .success(let profile):
continuation.resume(returning: profile)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
The detail that's easiest to get wrong here is that resume has to be called exactly once per execution path. If the original completion handler can, in some edge case, fire twice -a real bug we found in a third-party SDK that retried internally and occasionally triggered the callback twice after a timeout- the Swift runtime catches it and crashes with an explicit message. That's an uncomfortable failure to hit in production, but preferable to the silent alternative: without that check, a double resume would have left the continuation in an undefined state with no warning at all.
Async let and TaskGroup: real parallelism, not just sugar
The completion-handler pattern that generated the ugliest code was fetching two or three resources in parallel and waiting for all of them before continuing, usually with DispatchGroup:
let group = DispatchGroup()
var profile: UserProfile?
var avatar: UIImage?
group.enter()
fetchUserProfile(id: id) { result in
profile = try? result.get()
group.leave()
}
group.enter()
fetchAvatar(id: id) { image in
avatar = image
group.leave()
}
group.notify(queue: .main) {
// use profile and avatar
}
With async let, the same parallelism is expressed without shared mutable state or a DispatchGroup that has to be balanced by hand:
async let profile = fetchUserProfile(id: id)
async let avatar = fetchAvatar(id: id)
let (loadedProfile, loadedAvatar) = try await (profile, avatar)
Both calls start immediately and run in parallel; the combined await waits for both. When the number of parallel tasks isn't known at compile time -say, downloading one thumbnail per item in a list whose size depends on the server response- async let doesn't work, since it requires declaring each task separately. That's where TaskGroup comes in:
func fetchThumbnails(for items: [GalleryItem]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for item in items {
group.addTask {
try await self.downloadThumbnail(url: item.thumbnailURL)
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
If a single download throws, withThrowingTaskGroup automatically cancels the group's remaining tasks before propagating the error upward. With DispatchGroup, that cancellation had to be written by hand, and in practice it almost never was: one failed item left the rest downloading anyway, for a result that was going to be discarded regardless.
What didn't work: a Task not tied to the view's lifecycle
Our first migration attempt on a product detail screen replaced a completion handler with this, inside viewDidLoad:
override func viewDidLoad() {
super.viewDidLoad()
Task {
let details = try await productService.fetchDetails(id: productId)
self.updateUI(with: details)
}
}
It compiled, worked in the happy path, and passed code review without anyone flagging the problem. The problem showed up in manual testing: if the user opened the detail screen and closed it quickly -before fetchDetails finished- the Task kept running. When the network request finally came back, updateUI ran against a view that was no longer in the visible hierarchy. It didn't always crash: most of the time it just wasted a network request and some CPU updating something nobody would see, but in one case we hit a real crash because updateUI assumed an IBOutlet was still connected, and at that point in the lifecycle it no longer was.
The cause was that the Task was never tied to anything: it's an unstructured Task, created loose inside viewDidLoad, with no relation to the controller's lifetime beyond self being strongly captured inside the closure. Nothing cancelled that task when the view disappeared.
The fix was to hold a reference to the Task and cancel it explicitly:
private var detailsTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
detailsTask = Task {
do {
let details = try await productService.fetchDetails(id: productId)
self.updateUI(with: details)
} catch {
// handle the error explicitly
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
detailsTask?.cancel()
}
Cancelling the Task doesn't interrupt fetchDetails mid-request by itself: URLSession honors cooperative cancellation, so if fetchDetails is built on URLSession.data(for:), cancellation does cut off the in-flight request. But if some part of the code has a loop or a long computation, it needs to check Task.isCancelled explicitly for cancellation to actually take effect there, instead of assuming that calling .cancel() on the outer reference is enough.
In SwiftUI, the same problem has a more direct solution: the .task modifier automatically ties the task's lifetime to the view's lifetime and cancels it on its own when the view disappears, with no manual reference to keep track of:
struct ProductDetailView: View {
let productId: String
@State private var details: ProductDetails?
var body: some View {
content
.task {
details = try? await productService.fetchDetails(id: productId)
}
}
}
MainActor instead of DispatchQueue.main.async
The other change that raised questions was where to put UI updates. The old pattern ended every completion handler with DispatchQueue.main.async. The equivalent, and safer, approach in Swift Concurrency is annotating the function or property that touches UI with @MainActor, instead of remembering to wrap each individual update:
@MainActor
func updateUI(with details: ProductDetails) {
titleLabel.text = details.name
priceLabel.text = details.formattedPrice
}
The compiler, not a team convention, is what prevents updateUI from being called off the main actor. That turns an error that used to be caught only at runtime -or not even then, if the bug was intermittent- into a compile-time error if someone tries to call it from a context that doesn't guarantee it.
Where we draw the line on migration
We don't convert working code to async/await just because it's possible. We migrate the screens where nested completion handlers made it hard to reason about execution order or about what happened if one of the calls failed midway, which is exactly where structured concurrency removes real bugs. Where a completion-handler API stays isolated and stable, we wrap it once with withCheckedThrowingContinuation at the system boundary and let the rest of the app speak async/await, instead of mixing both styles screen by screen with no consistent rule for which one wins.