Before 2021, writing asynchronous Swift code meant callback hell — nested closures, manual error propagation, and a lot of mental gymnastics. Swift 5.5 introduced async/await, and for the first time, async code could finally be written linearly, just like synchronous code.
This article skips the theory. We’re starting with how to use it in real projects — from replacing callback-based network calls, to parallel requests with async let, to protecting shared state with actor. All code is production-ready for your next project.
In a recent project, I migrated our entire networking layer from GCD completion handlers to async/await. The original code had 47 completion handlers across 12 files — each with its own error handling pattern. After the migration, the total line count dropped by 35%, and three classes that existed solely to manage callback chains were deleted entirely. The hardest part wasn’t the syntax change — it was convincing the team that await doesn’t block the UI thread. Once we ran Instruments and saw the main thread was still responsive during async calls, everyone was convinced.
The biggest surprise was how much simpler error handling became — no more nested if let / guard let chains inside callbacks. With async/await, a single do-catch block covers the entire flow. I’ve since adopted async/await as the default for all new networking code, and I estimate it saves me about 30 minutes per feature compared to the old completion handler approach.
Code 1: Callback Hell vs async/await
Here’s a typical network request using closures — a pattern you’ve definitely written before:
// THE OLD WAY: closure-based networking
func fetchUserProfile(
completion: @escaping (Result<UserProfile, Error>) -> Void
) {
let url = URL(string: "https://api.example.com/me")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "Network", code: -1)))
return
}
do {
let profile = try JSONDecoder().decode(UserProfile.self, from: data)
completion(.success(profile))
} catch {
completion(.failure(error))
}
}.resume()
}
// Calling it — nested Result + guard + do-catch
fetchUserProfile { result in
switch result {
case .success(let profile):
fetchFriends(for: profile.id) { friendsResult in
switch friendsResult {
case .success(let friends):
// finally update UI... 3 levels deep
DispatchQueue.main.async {
self.updateUI(profile: profile, friends: friends)
}
case .failure(let error):
print("Friends failed: \(error)")
}
}
case .failure(let error):
print("Profile failed: \(error)")
}
}
// Problem: success and failure are expressed through two optional parameters.
// The caller has to figure out which one is valid. When you chain requests,
// it quickly becomes nested if let and guard statements that are hard to follow.
Now the same functionality, rewritten with async/await:
// THE NEW WAY: async/await — same logic, linear reading
func fetchUserProfile() async throws -> UserProfile {
let url = URL(string: "https://api.example.com/me")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(UserProfile.self, from: data)
}
func fetchFriends(for userId: String) async throws -> [Friend] {
let url = URL(string: "https://api.example.com/users/\(userId)/friends")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Friend].self, from: data)
}
// Calling it — linear, readable, no nesting
func loadProfile() async {
do {
let profile = try await fetchUserProfile()
let friends = try await fetchFriends(for: profile.id)
await MainActor.run {
updateUI(profile: profile, friends: friends)
}
} catch {
print("Failed: \(error)")
}
}
// You read the code from top to bottom. await acts like a pause button —
// the function stops here while the network request completes, doesn't block
// the UI thread, and resumes when the data comes back.
Code 2: async let for Parallel Requests
When you have independent requests that don’t depend on each other, use async let to run them in parallel — roughly twice as fast as running them sequentially:
// SEQUENTIAL — ~1.2 seconds total (two 600ms requests one after another)
func loadDashboardSequential() async throws -> Dashboard {
let user = try await fetchUserProfile() // 600ms
let notifications = try await fetchNotifications() // 600ms
return Dashboard(user: user, notifications: notifications)
}
// PARALLEL with async let — ~0.6 seconds total (both run at the same time)
func loadDashboardParallel() async throws -> Dashboard {
async let user = fetchUserProfile() // starts immediately, doesn't wait
async let notifications = fetchNotifications() // starts immediately too
// await waits for both to finish
return try await Dashboard(
user: user,
notifications: notifications
)
}
// Real-world usage in a SwiftUI view
struct DashboardView: View {
@State private var dashboard: Dashboard?
var body: some View {
List {
if let d = dashboard {
Text(d.user.name)
ForEach(d.notifications) { Text($0.title) }
}
}
.task {
do {
dashboard = try await loadDashboardParallel()
} catch {
print("Dashboard failed: \(error)")
}
}
}
}
The key difference: async let starts a task but doesn’t wait — execution continues. await waits for the result. When you have three independent network calls (user info, notifications, app config), async let runs them all simultaneously instead of one by one.
Code 3: TaskGroup for Batch Concurrency
async let works great when you know the number of tasks at compile time. But when you have a dynamic list — like downloading 50 images — use TaskGroup to manage them all with automatic cancellation:
// Batch download with automatic concurrency control
func downloadImages(urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
var results: [URL: UIImage] = [:]
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw DownloadError.invalidImage(url)
}
return (url, image)
}
}
for try await (url, image) in group {
results[url] = image
}
return results
}
}
// In a real app — download product images for a shopping list
func loadProducts() async {
let productIds = ["p1", "p2", "p3", "p4", "p5"]
let urls = productIds.map { URL(string: "https://cdn.example.com/products/\($0).jpg")! }
do {
let images = try await downloadImages(urls: urls)
// All 5 downloads run concurrently
// If one fails, the entire group cancels automatically
await MainActor.run {
productCollectionView.reloadData()
}
} catch {
print("Download failed: \(error)")
}
}
// Advanced: limit concurrency to avoid overwhelming the server
func downloadWithLimit(urls: [URL], maxConcurrent: Int = 5) async throws -> [URL: Data] {
try await withThrowingTaskGroup(of: (URL, Data).self) { group in
var results: [URL: Data] = [:]
for (index, url) in urls.enumerated() {
// Only allow maxConcurrent tasks at a time
if index >= maxConcurrent {
// Wait for one task to finish before adding the next
if let (completedURL, data) = try await group.next() {
results[completedURL] = data
}
}
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return (url, data)
}
}
for try await (url, data) in group {
results[url] = data
}
return results
}
}
Code 4: Actor for Data Race Protection
One of the most dangerous bugs in concurrent code is data races — two threads writing to the same variable at the same time. Before async/await, you’d use NSLock or DispatchQueue to protect shared state. Now, use actor:
// ❌ DANGEROUS: class with mutable state accessed from multiple threads
class UnsafeCache {
var entries: [String: Data] = [:]
func store(_ data: Data, forKey key: String) {
entries[key] = data // data race! Two threads can write simultaneously
}
func retrieve(forKey key: String) -> Data? {
return entries[key] // data race! Reading while another thread writes
}
}
// ✅ SAFE: actor guarantees exclusive access
actor ImageCache {
private var entries: [String: Data] = [:]
func store(_ data: Data, forKey key: String) {
entries[key] = data // safe: only one task can execute at a time
}
func retrieve(forKey key: String) -> Data? {
return entries[key] // safe: exclusive access guaranteed
}
func clear() {
entries.removeAll() // safe: no data race possible
}
}
// Usage — actor methods are called with await
let cache = ImageCache()
await cache.store(someData, forKey: "avatar")
if let data = await cache.retrieve(forKey: "avatar") {
print("Got \(data.count) bytes")
}
// Actor isolation means the compiler enforces thread safety:
// you CANNOT access actor properties directly — you must go through await.
// This is compile-time data race prevention, not runtime.
Code 5: @MainActor for UI Thread Switching
A common mistake: updating UI from a background thread. Before async/await, you’d wrap everything in DispatchQueue.main.async { }. With @MainActor, the compiler ensures all UI updates happen on the main thread — at compile time:
// ❌ BEFORE: manual Dispatch queue switching
class ProfileViewModel {
var user: UserProfile?
func loadUser() {
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let data = data, let self = self else { return }
let profile = try? JSONDecoder().decode(UserProfile.self, from: data)
// Must remember to switch to main thread — easy to forget!
DispatchQueue.main.async {
self.user = profile // UI update
}
}.resume()
}
}
// ✅ AFTER: @MainActor handles the thread switching for you
@MainActor
class ProfileViewModel {
var user: UserProfile?
func loadUser() async {
let (data, _) = try await URLSession.shared.data(from: url)
user = try JSONDecoder().decode(UserProfile.self, from: data)
// No DispatchQueue.main.async needed!
// @MainActor guarantees this runs on the main thread.
}
}
// In SwiftUI — .task automatically inherits MainActor for views
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
VStack {
if let user = viewModel.user {
Text(user.name)
Text(user.email)
} else {
ProgressView()
}
}
.task {
await viewModel.loadUser()
// .task inherits @MainActor from the View,
// so the entire chain runs on the main thread automatically.
}
}
}
Code 6: Migrating a Real Project from GCD to async/await
Here’s the before/after of a real feature I migrated — a user profile screen that loads data from three endpoints, processes images on a background queue, and updates the UI on the main thread:
// ============== BEFORE: GCD + closures ==============
class ProfileLoader {
private let group = DispatchGroup()
private var profile: UserProfile?
private var friends: [Friend] = []
private var avatar: UIImage?
func loadEverything(completion: @escaping () -> Void) {
// Request 1
group.enter()
URLSession.shared.dataTask(with: profileURL) { [weak self] data, _, _ in
defer { self?.group.leave() }
guard let data = data else { return }
self?.profile = try? JSONDecoder().decode(UserProfile.self, from: data)
}.resume()
// Request 2
group.enter()
URLSession.shared.dataTask(with: friendsURL) { [weak self] data, _, _ in
defer { self?.group.leave() }
guard let data = data else { return }
self?.friends = (try? JSONDecoder().decode([Friend].self, from: data)) ?? []
}.resume()
// Request 3 — then process on background queue
group.enter()
URLSession.shared.dataTask(with: avatarURL) { [weak self] data, _, _ in
guard let data = data, let img = UIImage(data: data) else {
self?.group.leave()
return
}
DispatchQueue.global(qos: .userInitiated).async {
let resized = self?.resize(img, to: CGSize(width: 100, height: 100))
DispatchQueue.main.async {
self?.avatar = resized
self?.group.leave()
}
}
}.resume()
// When all 3 are done
group.notify(queue: .main) {
completion()
}
}
private func resize(_ image: UIImage, to size: CGSize) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, false, 0)
image.draw(in: CGRect(origin: .zero, size: size))
let result = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return result ?? image
}
}
// ============== AFTER: async/await ==============
@MainActor
class ProfileLoader {
private var profile: UserProfile?
private var friends: [Friend] = []
private var avatar: UIImage?
func loadEverything() async {
// All 3 requests run in parallel — no DispatchGroup needed
async let profileData = URLSession.shared.data(from: profileURL)
async let friendsData = URLSession.shared.data(from: friendsURL)
async let avatarData = URLSession.shared.data(from: avatarURL)
// Await all results
if let (data, _) = try? await profileData {
profile = try? JSONDecoder().decode(UserProfile.self, from: data)
}
if let (data, _) = try? await friendsData {
friends = (try? JSONDecoder().decode([Friend].self, from: data)) ?? []
}
if let (data, _) = try? await avatarData {
// Image processing on background thread
avatar = await Task.detached(priority: .userInitiated) {
let img = UIImage(data: data) ?? UIImage()
return self.resize(img, to: CGSize(width: 100, height: 100))
}.value
// Automatically back on @MainActor after .value
}
}
private func resize(_ image: UIImage, to size: CGSize) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, false, 0)
image.draw(in: CGRect(origin: .zero, size: size))
let result = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return result ?? image
}
}
The difference is striking. The GCD version has manual DispatchGroup bookkeeping, [weak self] everywhere, and three separate resume() calls. The async/await version reads linearly — each request is two lines, parallelism is automatic, and thread safety is enforced at compile time.
Common Pitfalls
Does async/await block the main thread? No. await only suspends the current function. The thread is free to handle UI events or other tasks while the asynchronous operation runs. This is the key difference between await and synchronous waiting.
Can I use locks inside async functions? Be very careful. Async functions may switch threads before and after an await point. If you place a lock around an await, you risk deadlocks. Use actor instead of manual locking whenever possible.
Can I await a non-async function? No. await must be used with async functions. But async functions can call synchronous functions without await.
Final Thoughts
Moving from callbacks to async/await is essentially a shift from “tell the system to notify me when you’re done” to “I’ll wait here, but without blocking anyone else.” The code is shorter, clearer, and easier to maintain.
The real-world patterns above — parallel requests with async let, batch downloads with TaskGroup, thread safety with actor, and UI updates with @MainActor — cover the majority of async use cases in production apps. Start by migrating your oldest network code, and within a day you’ll wonder why you ever used completion handlers.
