All articles
iOS10 min read

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…

K
Karan Pal
Author
Image generated by AI
Image generated by AI

🧠 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:

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:

🖼 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:

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:

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.

🧠 When to Use async let:

🚫 When NOT to Use It:

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:

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

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 -> UIImage

Now 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:

🧠 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:

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:

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! 🚀

● The newsletter

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