From Callbacks to Structured Concurrency: A Practical Guide for Swift 5.5+
Callbacks and GCD can hide bugs and leak work. Swift 5.5+ structured concurrency—async/await, TaskGroup, and actors—offers a compile-time-safe replacement. This guide covers wrapping callbacks, parallel fan-out, actor isolation, and migration steps.
07 Sept 2025, 11:53 UTC

Why the Old Patterns Still Bite
Completion‑handler callbacks and Grand Central Dispatch (GCD) queues feel natural when you first learn Swift. They let you fire off work and react when it’s done. But as an app grows, the code that used to look tidy starts to spiral into “callback hell” and hard‑to‑reason‑about executor hops. The result is hidden bugs, leaked work, and a maintenance burden that grows with every new asynchronous call.
The Structured Concurrency Thesis
Swift 5.5+ introduces async/await, Task and TaskGroup, and actors to replace those patterns. The core idea is simple: make every asynchronous boundary explicit and tie the lifetime of work to a parent task. When you adopt these features, the compiler turns your code into a state machine that guarantees:
- Suspension points are visible with
await. - Child work is automatically cancelled if a parent is cancelled.
- Mutable state is isolated to a single executor, eliminating data races at compile time.
- Sendable types are checked, so only safe data moves across concurrency domains.
Section 1: Migrating a Callback API with Continuations
Suppose you have an old networking layer that uses a completion handler:
func fetchUser(id: Int, completion: @escaping (Result) -> Void) {
URLSession.shared.dataTask(with: URL(string: "https://api.example.com/users/\(id)")!) { data, _, error in
if let error = error { return completion(.failure(error)) }
guard let data = data, let user = try? JSONDecoder().decode(User.self, from: data) else { return completion(.failure(NSError(domain: "Parse", code: 0))) }
completion(.success(user))
}.resume()
}
Wrap it in an async function using withCheckedContinuation:
func fetchUser(id: Int) async throws -> User {
try await withCheckedContinuation { continuation in
fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}
Now callers can await the result, and the compiler will enforce that fetchUser does not run on the main actor unless you explicitly mark it.
Section 2: Parallel Fan‑Out with TaskGroup
Fetching multiple endpoints concurrently is a common pain point. The old approach used DispatchGroup and manual state aggregation. With TaskGroup the syntax is clearer and the lifetime is scoped automatically.
struct UserProfile { let user: User; let posts: [Post] }
func fetchProfile(userID: Int) async throws -> UserProfile {
try await withThrowingTaskGroup(of: (String, Any).self) { group in
group.addTask { ("user", try await fetchUser(id: userID)) }
group.addTask { ("posts", try await fetchPosts(userID: userID)) }
var user: User!
var posts: [Post]!
for try await (key, value) in group {
switch key {
case "user": user = value as? User
case "posts": posts = value as? [Post]
default: break
}
}
return UserProfile(user: user, posts: posts)
}
}
Key points:
- All child tasks share the same parent
TaskGroupand will be cancelled together if the parent is cancelled. - The
for awaitloop aggregates results in the order they finish. - Because the group is defined inside an async function, the compiler knows the entire operation is Sendable.
Section 3: Actors for Thread‑Safe State
Actors are compile‑time isolated units of mutable state. Any access to an actor’s property must cross an await boundary, so the compiler guarantees no concurrent writes.
@MainActor
class ViewModel: ObservableObject {
@Published var profile: UserProfile?
@Published var error: Error?
func loadProfile(userID: Int) {
Task { // inherits MainActor context
do {
let profile = try await fetchProfile(userID: userID)
self.profile = profile
} catch {
self.error = error
}
}
}
}
Because Task { } inherits the surrounding actor context, launching a task from a UI action keeps the UI responsive. If you need background work, explicitly detach with Task.detached { }.
Section 4: Trade‑Offs and Practical Checks
While structured concurrency simplifies many patterns, there are caveats:
- Strict Concurrency Checking (SWIFT_STRICT_CONCURRENCY = complete) will surface hundreds of Sendable warnings in legacy code. Use
@preconcurrencyto silence or incrementally migrate. - Async functions that never
awaitstill incur a small overhead. Profile hot paths withswift -Oand Instruments. - Actors can deadlock if two actors await each other. Design boundaries so that one actor only calls methods on another, never the reverse.
- Cancellation is cooperative. Long‑running synchronous loops inside a Task must periodically check
Task.isCancelledor usewithTaskCancellationHandler. - Cross‑platform toolchains (Linux, Windows) may lag in runtime parity. Verify deployment targets and run
swift build --sanitize=threadto catch data races that static analysis misses.
Practical checklist before a full migration:
- Enable strict concurrency in Xcode:
Build Settings > Swift Strict Concurrency Checking = complete. - Run
swift build --sanitize=threadto surface hidden races. - Wrap one callback API with
withCheckedContinuationand replace a single call site withawait. - Introduce a TaskGroup for a fan‑out scenario and observe the compiler’s error aggregation.
- Add an actor for a shared state object and annotate the UI view model with
@MainActor.
Actionable Closing
Structured concurrency is not a silver bullet, but it gives you a language‑level contract for correctness. Start small: wrap one legacy API, replace a GCD queue with a TaskGroup, and trust the compiler to catch race conditions. Over time, the pattern will spread through the codebase, making async code easier to read, test, and maintain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.