Swift async/await: A Practical Engineering Choice for Modern Apps
Replace nested completion handlers with Swift async/await for readable asynchronous code, with version checks and concurrency limits.
11 Jul 2025, 09:55 UTC

The problem: completion handlers that never end
You need to fetch a user profile, then the user's recent posts, then the comments on the newest post. Each step is asynchronous. With completion handlers, the code drifts rightward, error handling is duplicated at every level, and adding a retry or a timeout means touching every closure. By the third network step you are deep inside nested closures, and the success path is harder to read than the failure path.
Useful takeaway: Swift async/await lets you write that same flow as linear code. It is a sound engineering decision when your deployment target supports Swift 5.5 or later and you treat concurrency as a design constraint rather than a syntax shortcut.
From nested closures to linear flow
Here is the shape of the older approach:
fetchProfile(userId) { profileResult in
switch profileResult {
case .success(let profile):
fetchPosts(for: profile) { postsResult in
switch postsResult {
case .success(let posts):
guard let newest = posts.first else { return }
fetchComments(for: newest) { commentsResult in
// handle comments
}
case .failure(let error):
// handle error
}
}
case .failure(let error):
// handle error
}
}
Swift 5.5 introduced async/await, which compiles asynchronous work into state machines scheduled by Swift's cooperative thread pool. The same logic can read top-to-bottom:
func fetchCommentsForNewestPost(userId: String) async throws -> [Comment] {
let profile = try await fetchProfile(userId: userId)
let posts = try await fetchPosts(for: profile)
guard let newest = posts.first else { return [] }
return try await fetchComments(for: newest)
}
The await keyword marks a suspension point: the function pauses there without blocking the underlying thread, and resumes when the result is ready. try await propagates errors through the normal throw mechanism instead of nested Result switches.
Worked example: chained fetches in a Swift target
This example assumes Xcode 13 or later, Swift 5.5 or later, and a deployment target of iOS 15, macOS 12, tvOS 15, or watchOS 8, because the async variants of URLSession are tied to those OS releases.
import Foundation
struct UserProfile: Codable { let id: String; let name: String }
struct Post: Codable { let id: String; let title: String }
actor FeedService {
private let session = URLSession.shared
func profile(for userId: String) async throws -> UserProfile {
let url = URL(string: "https://api.example.com/users/\(userId)")!
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode(UserProfile.self, from: data)
}
func posts(for profile: UserProfile) async throws -> [Post] {
let url = URL(string: "https://api.example.com/users/\(profile.id)/posts")!
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
}
// Call from a synchronous context
Task {
let service = FeedService()
do {
let profile = try await service.profile(for: "user-123")
let posts = try await service.posts(for: profile)
print(posts.map { $0.title })
} catch {
print("Fetch failed: \(error)")
}
}
Where to run: Xcode, in a new iOS or macOS target. Permissions: none beyond normal app entitlements for network access. Placeholders: replace https://api.example.com/... with your real endpoints. Expected check: the project builds with Swift 5.5+, and running it prints post titles or an error without crashing. Risk: if this code runs on the main actor—such as inside a UIViewController—the Task inherits that actor context and can block UI updates while awaiting. Move network work off the main actor or explicitly detach the task with Task.detached.
Trade-offs and limits
- Deployment floor. The async/await language feature requires Swift 5.5. Many async-ready system APIs, including
URLSession.data(from:), require iOS 15/macOS 12 or later. If you support iOS 13 or 14, keep completion handlers for those code paths or wrap async calls behind availability checks. - Thread pool pressure. Swift schedules concurrent work on a cooperative thread pool. Launching hundreds of unstructured
Taskinstances can exhaust the pool and increase latency. Preferasync letfor parallel child work and avoid fire-and-forget tasks in latency-sensitive loops. - Debugging. Breakpoints behave differently across suspension points, and stack traces can span resumed continuations. Use structured logging and Instruments rather than relying only on breakpoints.
Verify before you adopt
- Open Terminal and run
swift -version. Confirm the toolchain reports Swift 5.5 or newer. - In Xcode, create a file with one
async throwsfunction and call it from aTask. Build for iOS 15 or macOS 12. - Enable strict concurrency checking in your build settings (
Swift Language Versionset to Swift 5 withStrict Concurrencycomplete) to catch actor isolation and data-race warnings early.
Closing
Start small: convert one leaf method, such as a single network request or disk read, to async throws. Call it from a Task, handle errors with do/catch, and measure with Instruments. If the code reads linearly and the build stays clean, expand to the next call site. Do not rewrite an entire codebase in one pass; adopt async/await where it removes nesting, and leave stable completion-handler code alone.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.