Making Swift Concurrency Work: async/await, TaskGroups, and Actors in Practice
Learn how Swift’s async/await, TaskGroups, and actors replace callback hell and thread‑safety headaches. A practical guide with code, trade‑offs, and actionable steps for clean, maintainable async code.
02 Aug 2026, 07:38 UTC

Problem: Callback Hell and Hard‑to‑Debug State
When you first write network code in Swift, you’re likely to use completion handlers. They force you to nest callbacks, make error handling a pain, and leave you with a hard‑to‑read call stack. If you need to run several requests in parallel, you end up juggling DispatchQueues or OperationQueues, and you still have to worry about thread safety.
Thesis: Structured Concurrency Solves These Pain Points
Swift 5.5 introduced async/await and a set of runtime features that let you write asynchronous code that looks and behaves like synchronous code, while the compiler and runtime keep track of task lifecycles and data isolation. The result is cleaner code, fewer bugs, and a natural cancellation model.
1. Linearizing Async Code with async/await
Instead of a completion handler that receives a Result, you write an async function that throws:
func fetchJSON(from url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(for: url)
return data
}
Calling it is just a one‑liner inside a Task {} or another async context:
Task {
do {
let data = try await fetchJSON(from: myURL)
// process data
} catch {
// handle error
}
}
The compiler guarantees that the call is awaited, so you don’t accidentally drop a response. Errors bubble up naturally, so you don’t need nested Result handling.
2. Structured Concurrency with TaskGroups
When you need to fire off many independent requests, a TaskGroup gives you a clean way to launch, wait for, and cancel them all together. The group’s lifetime is tied to the surrounding async function, so if the function exits early, all child tasks are cancelled automatically.
func fetchAll(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await fetchJSON(from: url) }
}
var results: [Data] = []
for try await data in group {
results.append(data)
}
return results
}
}
Running this from a view:
Task {
do {
let dataArray = try await fetchAll(urls: myURLs)
// update UI on main thread automatically
} catch {
// handle any error from any child task
}
}
Because the group owns the tasks, you never have stray background work. If the user navigates away, the view’s Task is cancelled, which in turn cancels the group and all child tasks.
3. Actors for Thread‑Safe State
Actors are a lightweight way to enforce isolation of mutable state. All accesses to an actor’s properties happen on the actor’s executor, so you don’t need locks.
actor Counter {
private var value = 0
func increment() {
value += 1
}
func current() -> Int { value }
}
func demoActor() async {
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask { await counter.increment() }
}
}
let final = await counter.current()
print("Final count: \(final)") // should be 1000
}
Running demoActor() under Thread Sanitizer shows no data races, even though 1000 tasks hit the actor concurrently. The actor serializes the increments, keeping the state consistent without explicit locking.
4. Trade‑offs and Limitations
- Migration Cost: Rewriting legacy completion‑handler code to async/await can be tedious. A common strategy is to wrap the old API in an async function using
withCheckedContinuationand refactor incrementally. - Fine‑Grained Actors: If you create an actor for every small piece of state, you pay a small executor overhead for each. For high‑throughput scenarios, consider grouping related state into a single actor.
- CPU‑Intensive Work: Async functions run on the current executor, which may be a thread pool. If you need heavy CPU work, launch it in a
Task.detachedor a background queue to avoid blocking UI threads. - Platform Support: Async/await requires Swift 5.5+ (Xcode 13+). Full runtime features, like structured cancellation, are only available on iOS 15+, macOS 12+, and newer.
5. Actionable Take‑aways
- Start using
asyncfunctions for any network or I/O bound work. The compiler will enforce proper awaiting. - Replace
DispatchGrouporOperationQueuepatterns withwithThrowingTaskGroupfor parallel work. It gives automatic cancellation and error propagation. - Encapsulate mutable shared state inside actors. Test with Thread Sanitizer to catch any accidental unsynchronized access.
- Measure performance with Xcode Instruments. A quick side‑by‑side comparison shows async/await has similar CPU/memory usage to GCD but with cleaner code.
- Plan incremental migration: wrap older callbacks in async adapters first, then refactor downstream code to await.
In short, Swift’s structured concurrency turns asynchronous programming into a first‑class, maintainable part of your codebase. The runtime takes care of cancellation, task lifecycles, and thread safety, letting you focus on business logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.