Mastering Async/Await in Swift
Bahadır Tarcan
December 15, 2025
Swift's async/await is a game-changer. After years of dealing with completion handlers, delegate callbacks, and Combine publishers, Apple finally gave us a clean, readable way to write asynchronous code. Let me share what I've learned from using it extensively in my apps.
Why Async/Await Matters
Before Swift 5.5, asynchronous code looked like this nightmare:
func fetchUserData(completion: @escaping (Result<User, Error>) -> Void) { fetchToken { tokenResult in switch tokenResult { case .success(let token): fetchProfile(token: token) { profileResult in // More nesting... } case .failure(let error): completion(.failure(error)) } } }
This "pyramid of doom" made code hard to read, debug, and maintain. Now, the same logic looks like this:
func fetchUserData() async throws -> User { let token = await fetchToken() let profile = await fetchProfile(token: token) return profile }
Clean, linear, and easy to understand. This isn't just syntactic sugar—it fundamentally changes how we think about asynchronous operations.
The Basics: async and await
Mark any function that performs asynchronous work with async:
func downloadImage(from url: URL) async throws -> UIImage { let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { throw ImageError.invalidData } return image }
When calling an async function, use await to pause execution until the result is ready:
let image = try await downloadImage(from: imageURL)
The beauty is that await doesn't block the thread. Swift suspends the function, frees up the thread for other work, and resumes when the result arrives.
Structured Concurrency with TaskGroup
Need to run multiple async operations in parallel? TaskGroup is your friend:
func downloadAllImages(urls: [URL]) async throws -> [UIImage] { try await withThrowingTaskGroup(of: UIImage.self) { group in for url in urls { group.addTask { try await self.downloadImage(from: url) } }
var images: [UIImage] = [] for try await image in group { images.append(image) } return images } }
All downloads run concurrently, and we collect results as they complete. If any task fails, the entire group cancels automatically. This is structured concurrency—child tasks are tied to their parent's lifecycle.
Actors: Safe Shared State
One of the trickiest parts of concurrent programming is managing shared mutable state. Actors solve this elegantly:
actor ImageCache { private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? { return cache[url] }
func store(_ image: UIImage, for url: URL) { cache[url] = image } }
Actors guarantee that only one task accesses their state at a time. No more manual locking, no more race conditions:
let cache = ImageCache() await cache.store(downloadedImage, for: url) let cachedImage = await cache.image(for: url)
Notice the await—accessing actor properties requires suspension because another task might be using the actor.
MainActor for UI Updates
UI work must happen on the main thread. The @MainActor attribute ensures this:
@MainActor class ProfileViewModel: ObservableObject { @Published var userName: String = "" @Published var profileImage: UIImage?
func loadProfile() async { let profile = try? await api.fetchProfile() userName = profile?.name ?? "" profileImage = profile?.image } }
Every property access and method call on this class runs on the main thread. No more DispatchQueue.main.async scattered throughout your code.
For one-off main thread work:
await MainActor.run { self.updateUI(with: data) }
Continuations: Bridging Old and New
Not all APIs support async/await yet. Continuations bridge the gap:
func fetchLocation() async throws -> CLLocation { try await withCheckedThrowingContinuation { continuation in locationManager.requestLocation { location, error in if let location = location { continuation.resume(returning: location) } else { continuation.resume(throwing: error ?? LocationError.unknown) } } } }
Important: Resume the continuation exactly once. Forgetting to resume causes a hang; resuming twice crashes.
Cancellation
Swift's concurrency model has built-in cancellation support:
func downloadWithProgress(url: URL) async throws -> Data { var data = Data() let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes { // Check for cancellation periodically try Task.checkCancellation() data.append(byte) }
return data }
When a Task is cancelled, Task.checkCancellation() throws CancellationError. You can also check Task.isCancelled for graceful handling:
if Task.isCancelled { // Clean up and return partial results return partialData }
Real-World Example: API Client
Here's how I structure API clients in my apps:
actor APIClient { private let session: URLSession private var authToken: String?
func fetchUser(id: String) async throws -> User { let request = try buildRequest(endpoint: "/users/\(id)") let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw APIError.invalidResponse }
return try JSONDecoder().decode(User.self, from: data) }
func setAuthToken(_ token: String) { self.authToken = token } }
The actor ensures thread-safe access to the auth token while allowing concurrent network requests.
Common Pitfalls
- **Forgetting await**: The compiler catches this, but it's easy to miss in complex expressions.
- **Blocking the main thread**: Don't call synchronous blocking code from async contexts on MainActor.
- **Over-parallelization**: Not everything needs to be parallel. Sometimes sequential is clearer and sufficient.
- **Ignoring cancellation**: Always handle cancellation in long-running tasks.
Migration Strategy
If you're migrating an existing codebase:
- Start with leaf functions (those that don't call other async code)
- Use continuations to wrap completion handlers
- Gradually move up the call stack
- Convert ViewModels last, as they often coordinate everything
Conclusion
Async/await has transformed how I write iOS apps. Code is more readable, bugs are easier to find, and concurrent operations are safer. If you haven't adopted it yet, start today. Your future self will thank you.
The Swift team knocked it out of the park with this one. Happy coding!
*Bahadır*