Replacing Callback Hell with Swift Structured Concurrency
Swift's structured concurrency with async/await makes async work linear and cancellable. See how a nested completion handler flow becomes a structured async chain with TaskGroup and actors, plus the limits to watch.
14 Jul 2025, 05:45 UTC

Three network calls chained with completion handlers turn a simple flow into a pyramid of closures that is hard to read, test, and cancel. The useful takeaway is that Swift's structured concurrency with async/await makes the lifetime of asynchronous work explicit, so cancellation and errors propagate with the call stack instead of being manually threaded through callbacks.
The callback problem you actually hit
Before Swift 5.5, URLSession work looked like nested closures. Fetch a user, then fetch that user's posts, then fetch comments for each post. Each step captures the next closure, error handling is duplicated, and there is no single place to cancel the whole chain.
Structured concurrency replaces that with linear code. An async function suspends at await points instead of handing control to a closure. A Task is a unit of asynchronous work with a parent-child relationship. When a parent task is cancelled, its children are cancelled automatically. That is the structure.
How structured concurrency changes the shape of code
async marks a function that can suspend. await marks the suspension point. The compiler enforces that you only call async functions from async contexts.
Task { ... } creates an unstructured child when you need to start work from synchronous code. Prefer structured APIs like TaskGroup for dynamic parallelism, because the group awaits all children before returning and propagates cancellation.
Actors protect mutable state. An actor serializes access to its isolated state. The compiler will flag data races when you attempt to mutate shared state from multiple concurrent tasks unless that state is actor-isolated.
MainActor pins work to the main thread. Mark UI-updating code with @MainActor or MainActor.run. Pushing heavy parsing or networking onto MainActor creates a bottleneck.
Worked example: user → posts → comments
The following illustrates the same flow with completion handlers versus async/await. Do not copy-paste as production code; it is a shape comparison.
// Completion handler shape
func fetchUser(id: Int, completion: @escaping (Result) -> Void) { ... }
func fetchPosts(for user: User, completion: @escaping (Result<[Post], Error>) -> Void) { ... }
fetchUser(id: 1) { result in
switch result {
case .success(let user):
fetchPosts(for: user) { result in
switch result {
case .success(let posts):
// further nesting...
case .failure(let e): ...
}
}
case .failure(let e): ...
}
}With async/await the flow is linear. URLSession provides an async API on Swift 5.5+.
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
func fetchPosts(for user: User) async throws -> [Post] {
let url = URL(string: "https://api.example.com/users/\(user.id)/posts")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
func loadFeed(for userId: Int) async throws -> [Post] {
let user = try await fetchUser(id: userId)
let posts = try await fetchPosts(for: user)
return posts
}Calling code can start the work with Task from a synchronous context, e.g. in a view controller lifecycle method. Cancellation is tied to the task's lifetime.
let task = Task {
do {
let posts = try await loadFeed(for: 1)
await MainActor.run {
// update UI
}
} catch {
// handle cancellation or network error
}
}For parallel fetches, TaskGroup keeps structure:
func fetchComments(for posts: [Post]) async throws -> [Comment] {
try await withThrowingTaskGroup(of: [Comment].self) { group in
for post in posts {
group.addTask {
try await fetchComments(for: post.id)
}
}
var all: [Comment] = []
for try await comments in group {
all.append(contentsOf: comments)
}
return all
}
}Trade-offs and limits to watch
Legacy APIs still use completion handlers. Bridging them requires withCheckedContinuation to resume exactly once. Resuming twice or never causes a runtime trap. Wrap the legacy call once and expose an async API.
Overusing Task.init creates unstructured concurrency. Tasks started without a parent are not cancelled when the caller goes away, making leaks and orphaned network requests likely.
MainActor misuse is a common performance pitfall. Annotating a whole service with @MainActor moves non-UI work to the main thread. Keep MainActor boundaries narrow and explicit.
Actors add safety but change call sites. Accessing actor-isolated state requires await. That can ripple through a codebase and increase suspension points.
A practical way to check structure is to enable strict concurrency checking in build settings and try to mutate a shared variable from multiple tasks. The compiler should flag the data race unless the variable is protected by an actor or synchronization primitive.
Actionable next step
Pick one callback-heavy flow in your app, e.g. a multi-step network sequence. Write an async version that uses await for each step and a TaskGroup for any parallel branches. Keep MainActor usage limited to UI updates, and bridge any legacy completion APIs with a single withCheckedContinuation wrapper. Review cancellation paths to ensure child tasks are cancelled when the parent is cancelled.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.