All articles
iOS9 min read

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 started with real…

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

🚀 Introduction

Remember the dark times when every network call came with a nested completion handler… which came with another completion handler… which came with an existential crisis? 😩

Well, Swift developers, rejoice — those callback pyramids are getting a makeover! 🎉

With Swift 5.5, Apple introduced async/await, a modern concurrency model that makes your asynchronous code look synchronous, feel cleaner, and play nicer with error handling.

In this three-part series, I’ll help you truly master Swift’s async/await:

By the end of this series, you’ll be ready to ditch callback hell and write code that’s clean, scalable, and future-proof.

⚠️ Note: async/await is available starting _Swift 5.5+_ (iOS 13+ with back-deployment, iOS 15+ natively).

Ready? Let’s bring some calm to your concurrency chaos.

🧠 What is async/await in Swift (and Why Should You Care)?

Swift’s async/await is part of a broader shift toward structured concurrency — a fancy term for “make async code less chaotic and more readable.”

But let’s break it down without the buzzwords:

🧵 So, what is async?

When you mark a function as async, you’re telling Swift:

“Hey, this function does something that takes time — like fetching data from a server or reading a file — so don’t block the main thread while it happens.”

Instead of waiting like a clingy ex, Swift just… moves on. 🚶

func fetchUserProfile() async -> User { 
   // pretend this is doing something time-consuming
}

This function can now run asynchronously — meaning it won’t block your app’s main thread while it completes its task.

⏳ And what does await do?

When you call an async function, you use await to say:

“Wait here until I get the result — but only pause _this_ function, not the whole app!”
let user = await fetchUserProfile()

This keeps your UI smooth and responsive, while your app does time-consuming work in the background. No more GCD spaghetti. 🍝

🤝 async/await vs completion handlers

Let’s compare:

Old way: Completion handlers

fetchUser { result in
    switch result {
    case .success(let user):
        print(user.name)
    case .failure(let error):
        print("Oops: \(error)")
    }
}

New way: async/await

do {
    let user = try await fetchUser()
    print(user.name)
} catch {
    print("Oops: \(error)")
}

💡 See that? Same logic, but now it reads top-down — like regular code. Much easier to write, debug, and actually understand at 2AM.

📌 Summary

✍️ Writing Your First async/await Function in Swift

Time to get our hands dirty and see how async/await really works under the hood — with zero third-party libraries and no mental gymnastics. Just pure Swift.

🛠 Step 1: A Synchronous Function (The Old School Way)

Let’s start with something familiar. Here’s a function that simulates fetching a user from a local database:

func getUserName() -> String {
    return "Swift Pal"
}

Simple. Straightforward. But let’s say this is now a network call, and you can’t just return it instantly. You’ll need to wait. And waiting = async.

🧪 Step 2: Make it Async

Let’s simulate a network delay using Task.sleep, and then convert this function to be async:

func getUserName() async -> String {
    // Sleep for 2 seconds (in nanoseconds!)
    try? await Task.sleep(nanoseconds: 2_000_000_000)
    return "Swift Pal"
}

✅ The async keyword says: “This function is now asynchronous.”

✅ The await inside Task.sleep says: “Hold on here, this takes time.”

🧵 Step 3: Call It from a Task

You can’t just call await from a regular function — you need to be in an asynchronous context. One way to do this is by using a Task block:

Task {
    let name = await getUserName()
    print("Hello, \(name) 👋")
}

Boom 💥 — this prints after 2 seconds, without freezing your UI. Feels like magic, works like logic ✨

🧯 Bonus: What if You Need to Throw?

Let’s say your function might fail (e.g. network issue). Just add throws like this:

func fetchProfile() async throws -> String {
    let success = Bool.random()
    if success {
        return "Async Swift 🧑‍💻"
    } else {
        throw URLError(.badServerResponse)
    }
}

And you call it like this:

Task {
    do {
        let profile = try await fetchProfile()
        print("Fetched: \(profile)")
    } catch {
        print("Error fetching profile: \(error)")
    }
}

🧠 TL;DR

Next, I’ll show how async/await completely transforms a messy, real-world API call written with GCD into something your future self will thank you for 🙏

🔄 Refactoring GCD Code to async/await: A Real Example

Now that you’ve got a grip on async and await, let’s see how this actually helps you in real Swift codebases — by replacing some good old-fashioned GCD spaghetti 🍝

Think of this section as:

“How would I refactor existing code that uses DispatchQueue and completion handlers?”

👴 The Legacy Way — Using GCD

Let’s say you have a method that simulates an API call using DispatchQueue:

func fetchUserName(completion: @escaping (String?, Error?) -> Void) {
    DispatchQueue.global().async {
        sleep(2) // simulate network delay

        let success = Bool.random()

        DispatchQueue.main.async {
            if success {
                completion("Swift Pal", nil)
            } else {
                completion(nil, URLError(.badServerResponse))
            }
        }
    }
}

And you’d call it like this:

fetchUserName { name, error in
    if let error = error {
        print("❌ Error: \(error)")
    } else if let name = name {
        print("✅ Fetched user: \(name)")
    }
}

It works. But it’s noisy. And that pyramid will only grow taller once you add more async steps.

✨ Refactored with async/await

Here’s the upgraded version using Swift concurrency:

func fetchUserName() async throws -> String {
    try await Task.sleep(nanoseconds: 2_000_000_000) // simulate delay

    let success = Bool.random()
    if success {
        return "Swift Pal"
    } else {
        throw URLError(.badServerResponse)
    }
}

Calling it becomes a joy:

Task {
    do {
        let name = try await fetchUserName()
        print("✅ Fetched user: \(name)")
    } catch {
        print("❌ Error: \(error)")
    }
}

🧘 Why This is Better (Beyond Just Syntax)

🧠 Real Talk:

When you refactor even one old networking method like this, you’ll start seeing how much mental overhead GCD had been silently adding all along.

And that’s the whole point of structured concurrency — which I’ll cover next 🧵

📌 Structured Concurrency in One Minute (Promise)

Okay, you’ve heard the term. Apple loves using it. But what is structured concurrency, and why does it sound like something from an advanced computer science class?

Here’s the non-boring version:

🧵 What is Structured Concurrency?

Structured concurrency is Swift’s way of saying:

“Let’s keep async tasks _organized_, _scoped_, and _tied to something_ — so they don’t wander off and cause chaos.”

In other words, tasks that you spawn should:

Think of it like this:

Without Structure (Bad old days):

With Structure (Swift’s async/await):

🏛️ Example: Task Group vs Detached Task

🚫 Detached Task (unstructured):

Task.detached {
    // Not tied to any parent task
    await doSomething()
}

This is a wild child. If it fails or leaks, you may not notice until things break.

✅ Structured Task:

Task {
    await doSomething()
}

This task is attached to its parent, such as a view or calling scope. When that parent is cancelled or deallocated — this task goes with it.

This is what makes Swift’s async/await model so safe and scalable.

🤯 TL;DR

🎬 Wrapping Up: async/await Just Clicked, Didn’t It?

If you’ve made it this far — congrats, you’re no longer afraid of the async monster hiding in your Swift codebase 😌🎉

Here’s what you’ve learned in Part 1:

You now understand the “why” and “how” — but we’re just getting warmed up 🔥

👀 Part 2 — Real-World Examples

In Part 2, we’ll get hands-on with:

Basically, I’ll take everything you learned here and actually build stuff with it. No more theory — just real code you can use in production 👨‍💻💪

**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…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-real-world-examples-for-ios-developers-part-2-b3e43b1c27e7

🎉 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