Mastering async/await in Swift: Real-World Examples for iOS Developers (Part 2)
Learn how to use async/await in your actual iOS projects — from network calls and image loading to chaining and parallel execution. Real…

🧠 Quick Recap: What We Covered in Part 1
If you missed Part 1 — or forgot it already (I forgive you) — here’s a rapid-fire refresher:
- async lets functions run without blocking your thread
- await pauses just your function until the result is ready
- It replaces old-school GCD and nested callbacks
- You can now write async code that reads like sync code
- Swift uses structured concurrency to help you manage tasks more safely and cleanly
Alright — time to put async/await to work 🧑🔧
**Mastering async/await in Swift: A Beginner’s Guide to Modern Concurrency (Part 1)** _Confused by Swift’s async/await? Learn the basics of async/await, how it improves your Swift code, and how to get…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-a-beginners-guide-to-modern-concurrency-part-1-88cdb659ac3b
🌐 Making an API Call with async/await
Let’s start with the most common async task in any app — fetching data from the internet. Whether you’re hitting your own backend or pulling memes from Reddit, URLSession + async/await is your new best friend.
🧱 The Setup: A Simple API
Let’s say you’re calling a basic endpoint that returns user data:
let url = URL(string: "https://jsonplaceholder.typicode.com/users/1")!❌ The Old Way: Completion Handlers
You’ve probably written something like this:
func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}
task.resume()
}It works. But it’s callback-y, hard to test, and annoying to follow.
✅ The async/await Version
Let’s clean it up:
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}That’s it. 👏
No closures, no dispatch queues, no Result type boilerplate.
🧑💻 Calling the Function
To call this in a SwiftUI View, background task, or inside a Task {}:
Task {
do {
let user = try await fetchUser()
print("User name: \(user.name)")
} catch {
print("❌ Failed to fetch user: \(error)")
}
}💡 Bonus Tip: URLSession.data(for:) vs data(from:)
You can also use this version for more control:
let request = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: request)This lets you inspect headers, status codes, etc. — great for custom requests, headers, or auth.
✅ Benefits Recap:
- Cleaner syntax
- Built-in error handling
- Easier to write tests for
- Reads top-down like normal code
🖼 Downloading Images Asynchronously (Without Freezing the UI)
Ever had your UI stutter because someone scrolled past 10 images at once? Yeah… not fun.
Using async/await, we can fetch images in the background without juggling DispatchQueue or writing custom URLSession wrappers.
🎯 Goal:
- Download an image from a URL
- Display it in your app (SwiftUI or UIKit)
- Keep the UI snappy
Let’s see how easy this gets.
🧪 The Core Image Download Function
func fetchImage(from url: URL) async throws -> UIImage {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw URLError(.badServerResponse)
}
return image
}Boom. That’s your reusable async image loader — with real error handling built-in.
🧑🎨 In SwiftUI (The Cleanest Way)
struct AsyncImageView: View {
let url: URL
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
ProgressView()
.task {
do {
image = try await fetchImage(from: url)
} catch {
print("Image failed: \(error)")
}
}
}
}
}
}No threads, no DispatchQueue, no third-party libraries. This is pure SwiftUI and Swift concurrency magic 🔮
🧠 For UIKit Fans
You can drop this inside any UIViewController or custom UITableViewCell:
Task {
do {
let image = try await fetchImage(from: url)
imageView.image = image
} catch {
print("Image load failed: \(error)")
}
}💡 Pro Tips:
- You can cache images using NSCache or a lightweight wrapper
- For SwiftUI, you also have AsyncImage in iOS 15+, but the above gives you more control and customization
- Consider debouncing or cancelling tasks during rapid scrolls (we’ll cover this in Part 3)
Next up: let’s chain multiple async operations in a clean, readable flow — no more callback pyramids 🔗
🔗 Chaining Async Calls (Without Pyramids of Doom)
In the old days, chaining asynchronous operations meant callback hell — nested closures, hard-to-follow logic, and error handling that looked like it came out of a horror movie 🧟♂️
With async/await, each step becomes a clean, top-down instruction, just like regular synchronous code.
📦 Example: Fetch User → Then Fetch Posts → Then Fetch Comments
Let’s build a fake flow you’d find in any social app:
struct User: Decodable {
let id: Int
let name: String
}
struct Post: Decodable {
let id: Int
let userId: Int
let title: String
}
struct Comment: Decodable {
let id: Int
let postId: Int
let body: String
}🧱 Step-by-Step Chaining with async/await
func fetchUser() async throws -> User {
let url = URL(string: "https://jsonplaceholder.typicode.com/users/1")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
func fetchPosts(for userId: Int) async throws -> [Post] {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts?userId=\(userId)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
func fetchComments(for postId: Int) async throws -> [Comment] {
let url = URL(string: "https://jsonplaceholder.typicode.com/comments?postId=\(postId)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Comment].self, from: data)
}Now the chained call becomes… readable. Like, actually readable:
🧘 Chaining Made Clean
Task {
do {
let user = try await fetchUser()
let posts = try await fetchPosts(for: user.id)
guard let firstPost = posts.first else { return }
let comments = try await fetchComments(for: firstPost.id)
print("User: \(user.name)")
print("First Post: \(firstPost.title)")
print("Comments count: \(comments.count)")
} catch {
print("Something went wrong: \(error)")
}
}🤯 No nesting. No DispatchGroup. No .flatMap gymnastics.
You’re just reading code like it’s a story:
“Get user → get their posts → get comments on first post.”
That’s the async/await magic ✨
Up next: let’s learn to run multiple async operations in parallel using async let — and see how to save time without writing ugly loops.
⚡ Running Async Tasks in Parallel with async let
By default, when you write:
let result = try await someFunction()Swift runs it sequentially — it waits before moving on to the next line. But what if you have multiple independent calls you could run at the same time?
That’s where async let comes in 💪
🎯 Real-World Case: Load User + Posts + Comments Together
Let’s say we want to load everything on the profile screen — but we don’t need to wait for one to finish before starting the others.
🧪 The Sequential (Slower) Way
let user = try await fetchUser()
let posts = try await fetchPosts(for: user.id)
let comments = try await fetchComments(for: posts.first?.id ?? 0)Each line waits for the previous one. What if we could overlap the network time?
⚡ The Parallel Way: async let
async let user = fetchUser()
async let posts = fetchPosts(for: 1)
async let comments = fetchComments(for: 1)
let (resolvedUser, resolvedPosts, resolvedComments) = try await (user, posts, comments)Swift kicks off all 3 tasks in parallel, then waits for the results at the end.
- ✅ Faster total time (since they run concurrently)
- ✅ Still type-safe and easy to debug
- ✅ You can try await them all at once
🧠 When to Use async let:
- When you’re making independent async calls
- When no result depends on the previous one
- When you want maximum performance with minimal fuss
🚫 When NOT to Use It:
- If order matters and each result depends on the last
- If you’re not going to use the result — async let must always be awaited
You can’t “fire and forget” with async let — for that, you’d need Task {} or Task.detached {} (which we’ll explore in Part 3).
⚠️ Bonus Tip:
async let is structured concurrency — Swift keeps track of these tasks and makes sure they’re all awaited before exiting the scope. If you forget, it’ll warn or crash. It’s like Swift holding your coffee while you code ☕🧑💻
Next up: let’s talk about how error handling works with async/await in real-world apps — because one try? isn’t going to cut it 😅
🧯 Error Handling in async/await (Without Losing Your Mind)
With async/await, Swift lets you handle errors using the same old tools you already know and love:
- do { try await … } catch { … }
- try? for soft fails
- try! for… living dangerously 😈
But now you can do it with clean, linear code.
⚠️ Example: Failing API Call
Let’s reuse our earlier function:
func fetchUser() async throws -> User {
let url = URL(string: "https://jsonplaceholder.typicode.com/users/9999")! // Invalid ID
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}✅ Standard Error Handling with do-catch
Task {
do {
let user = try await fetchUser()
print("Fetched user: \(user.name)")
} catch {
print("❌ Failed to fetch user: \(error.localizedDescription)")
}
}Simple, clear, and you can even pattern match specific errors in the catch block:
catch is DecodingError {
print("Failed to decode JSON")
}🤷 Soft Fails with try?
This is handy when the failure isn’t catastrophic and you want to fallback:
Task {
if let user = try? await fetchUser() {
print("User: \(user.name)")
} else {
print("User not found — showing guest mode 👻")
}
}No catch, no crash, no drama.
🚨 Living on the Edge with try!
let user = try! await fetchUser()Don’t do this unless you know the call will succeed (e.g. in a test or internal-only tool).
If it fails — 💥 boom — your app goes down like a ship with no lifeboats.
🧠 Bonus: Combining Multiple Tries
Task {
do {
async let user = fetchUser()
async let posts = fetchPosts(for: 1)
let (userResult, postResult) = try await (user, posts)
print("User: \(userResult.name), Posts: \(postResult.count)")
} catch {
print("Something failed: \(error)")
}
}A single catch can handle errors from multiple async tasks — as long as all are awaited in the same scope.
💡 Takeaway
- try await gives you powerful error handling without breaking readability
- You can choose from strict (try!), optional (try?), or catchable (do-catch)
- Chaining multiple async calls with try await keeps code flow intact and bugs easier to trace
Coming up next: a mini refactoring challenge — we’ll take a messy completion handler function and turn it into a clean async/await version.
🧪 Mini Refactor Challenge: From Callback Chaos to async Elegance
Let’s take a classic mess — a real-world completion-handler-laced function — and rewrite it with async/await.
❌ The Before: Callback Pyramids of Pain
func loadProfile(completion: @escaping (Result<User, Error>) -> Void) {
fetchUserFromServer { result in
switch result {
case .success(let user):
fetchAvatar(for: user.id) { avatarResult in
switch avatarResult {
case .success(let image):
completion(.success(User(id: user.id, name: user.name, avatar: image)))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}✅ It works.
❌ But your brain just took psychic damage reading it.
Spend some time, try to think of aync/await way of doing this, then read ahead.
✅ The After: async/await Glory
Let’s assume these two are now async functions:
func fetchUserFromServer() async throws -> User
func fetchAvatar(for userId: Int) async throws -> UIImageNow our loadProfile() looks like:
func loadProfile() async throws -> User {
let user = try await fetchUserFromServer()
let avatar = try await fetchAvatar(for: user.id)
return User(id: user.id, name: user.name, avatar: avatar)
}It’s readable, testable, and… dare I say it… enjoyable to write 😌
💡 Real Use Case
Think onboarding flow:
- Fetch user → get profile pic → pull app settings → all done in a single top-down block with error handling and retry logic that doesn’t require a diagram.
🧠 Bonus Tip:
You can even make loadProfile() use async let to parallelize tasks if avatar and user data come from independent sources (e.g., separate APIs).
async let user = fetchUserFromServer()
async let avatar = fetchAvatar(for: 1)
let profile = try await User(id: try await user.id, name: try await user.name, avatar: try await avatar)🎬 Wrapping Up: You Just Leveled Up
If Part 1 gave you the tools, Part 2 showed you how to swing the hammer 🔨
Here’s what you pulled off today:
- Made real API calls using async/await
- Loaded images without blocking the UI
- Chained async calls like a storybook, not a horror movie
- Parallelized tasks with async let to save seconds (and sanity)
- Handled async errors with grace — and no nested mess
- Refactored old-school callback chaos into modern Swift beauty
This isn’t just cleaner code — it’s code that scales, debugs easier, and reads like poetry (ok maybe limericks 📝).
Part 3: The Deep Stuff
If you thought today was fun, wait till we crack open:
- Task {} vs Task.detached {} — and what “structured concurrency” really means
- Cooperative cancellation (so you stop wasting CPU)
- Cleaning up long-running tasks
- Avoiding memory leaks and retain cycles in async closures
- When not to use async/await (yes, that’s a thing 👀)
- Debugging async like a pro, not a panicked print-statement spammer
It’s going to be 🔥 — and **Part 3** is where you go from async-aware to async-weaponized.
**Mastering async/await in Swift: Advanced Patterns, Cancellation, and Gotchas (Part 3)** _Take your Swift concurrency skills to the next level — learn how to manage tasks, handle cancellations, debug async…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-advanced-patterns-cancellation-and-gotchas-part-3-af1ea8996145
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read