All articles
iOS12 min read

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 bugs, and avoid common…

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

🧠 Quick Recap: You’ve Come a Long Way, Async Warrior 🥷

If you’ve followed along since Part 1, give yourself a high five 🙌

You’ve already mastered:

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

So… what’s left?

Well, Swift concurrency has power tools — and they come with sharp edges ⚠️

In this final part, we’ll show you:

Let’s finish strong 💪

️ Task vs Task.detached — Think of It Like ARC

Swift devs understand ARC — you create a strong reference, and that thing stays alive. You detach it, and it might go poof if no one holds onto it. Now apply that mental model to concurrency:

✅ Task {} – The Strong Reference

Task {
    await doSomething()
}

This creates a structured task or think of it like creating a strong reference to an async task:

Think of it as:

“You own me. If you’re deallocated, I go down with you.”

Just like a strong reference keeps objects alive — Task {} keeps tasks scoped and attached to where they were created.

🔓 Task.detached {} – The Unowned (Detached) Reference

Task.detached {
    await doSomething()
}

This one is unstructured or think of it like creating an unowned async task:

“You don’t own me. I’ll do my thing whether you’re alive or not.”

Perfect for fire-and-forget use cases: analytics, telemetry, background syncing, crash reporting, etc.

But just like unowned, you better be sure this task can survive without babysitting — otherwise, things might blow up 🔥

🎯 Real-World ARC-style Use Cases

Task {} → Strong reference → tied to lifecycle

.task {
    await loadProfile()
}

Used in SwiftUI: view appears → task starts

View disappears → task is cancelled

Like a retained reference tied to the view’s life.

Task.detached {} → Unowned reference → free-floating

Task.detached(priority: .background) {
    await logAnalytics()
}

🧠 TL;DR — With ARC Vibes

Task {} — Like a strong reference:

Task.detached {} — Like an unowned reference:

🔁 Gracefully Cancelling Async Tasks (Because Not Everything Deserves to Finish)

Swift’s structured concurrency model isn’t just about writing cleaner code — it’s also about being respectful of system resources.

Instead of letting long-running tasks continue when they’re no longer needed (hello battery drain 🔋), Swift gives us built-in cancellation tools.

Let’s break down how to use them properly.

🧹 The Problem: Uncancelled Tasks Waste Resources

Imagine this:

Even though the user is gone, your app is still doing work. That’s ✨ not ✨ ideal.

🧠 Swift to the Rescue: Cooperative Cancellation

Swift uses a cooperative cancellation model, which means:

Here’s how you do it.

✅ Checking for Cancellation

func loadHeavyData() async {
    for item in 0..<1000 {
        // Check for cancellation at intervals
        if Task.isCancelled {
            print("❌ Task cancelled — stopping early.")
            return
        }

        await process(item)
    }

    print("✅ All items processed.")
}

That’s it.

Swift gives you Task.isCancelled — you decide when to listen to it and exit gracefully.

🔌 How Cancellation Happens

1\. View disappears in SwiftUI

Any .task {} attached to that view gets cancelled automatically.

.task {
    await loadHeavyData() // will cancel if view is popped
}

2\. You manually cancel a task:

let task = Task {
    await loadHeavyData()
}

task.cancel()

This sets the cancellation flag. Your task will only stop if it checks for it using Task.isCancelled.

🧪 Bonus: Add Cleanup with defer

You can pair cancellation with defer to clean up anything — like invalidating timers, closing DB handles, etc.

func startProcessing() async {
    defer {
        print("🧼 Cleaning up before exit.")
    }

    guard !Task.isCancelled else { return }
    await doWork()
}

💡 Summary:

Next up: let’s talk about the dark side of async/await — mistakes, memory leaks, and weird behaviors that sneak in when you’re not paying attention 😈

☠️ Common async/await Mistakes That Will Haunt You (If You Let Them)

Just because your code compiles doesn’t mean it’s safe. Here are the most common async/await traps Swift developers fall into — and how to avoid them like a seasoned concurrency ninja 🥷

1\. ❗Forgetting to await

This is the async equivalent of leaving your app unlocked overnight. If you forget to await, the call won’t suspend, but the compiler won’t always warn you.

doSomethingAsync() // ← This runs but isn't awaited. Bad.

You must call it like this:

await doSomethingAsync()

Otherwise, you’re just starting a task and walking away without waiting for it to finish — which can lead to unexpected behavior, especially if that function throws.

2\. 🧠 Capturing self strongly in async closures

Just like regular closures, async closures can retain self, which leads to retain cycles and memory leaks — especially if you’re doing something like this in a view model or controller:

Task {
    await self.doImportantStuff() // ← retain cycle alert 🚨
}

Use \[weak self\] if you’re inside classes or escaping scopes:

Task { [weak self] in
    await self?.doImportantStuff()
}

Or even better, refactor into methods that don’t depend on self at all when possible.

3\. 🤯 Mixing GCD and async/await blindly

You might be tempted to use this hybrid mess:

DispatchQueue.global().async {
    Task {
        await loadData()
    }
}

This creates unnecessary layers of threads and makes task management chaotic.

✅ If you’re already in async-land, stay there. You usually don’t need to manually dispatch anymore — Swift handles it smartly under the hood.

4\. 💤 Blocking an async context

Do not block an async context with sleep or synchronous operations like this:

sleep(5) // ❌ Blocks the entire thread!

✅ Use:

try await Task.sleep(nanoseconds: 5_000_000_000)

Otherwise, you’ll freeze the thread and break Swift’s concurrency model.

5\. 🧪 Ignoring errors with try! in production

try! await fetchData() might feel clean, but one bad network response and your app is toast 🍞🔥

✅ Always use try? or proper do-catch in user-facing code.

💡 Recap: How to Stay Async-Safe

Up next: Let’s look at when not to use async/await — because sometimes, good ol’ GCD still deserves some love ❤️

⚖️ When Not to Use async/await (Yes, Really)

Swift’s async/await is powerful, elegant, and modern. But that doesn’t mean you should immediately refactor everything — sometimes, sticking with DispatchQueue, simple sync code, or even completion handlers is the better move.

Let’s break it down 🧠👇

❌ Don’t use async/await for micro-tasks

If you’re just dispatching something tiny to a background queue, like logging or a quick calculation, using async/await might actually overcomplicate things.

DispatchQueue.global().async {
    log("User opened screen")
}

This is fine. You don’t need Task.detached just to print a log statement.

❌ Avoid async/await for low-level sync utilities

Not everything that can be async should be async.

func calculateFibonacci(_ n: Int) -> Int {
    // CPU-bound, but no async involved
}

If you make this async just to match the rest of your code, you’re probably just adding overhead. Let CPU-bound functions stay sync — and run them on a background queue if needed.

❌ Don’t blindly refactor existing code that’s working perfectly

That 7-year-old networking service in your codebase that uses completion handlers? If it works, is tested, and isn’t slowing you down, maybe leave it alone. Async/await is cool, but massive refactors introduce risk for little gain.

✅ When to stick with GCD

GCD still shines in:

Async/await isn’t a total replacement — it’s an evolution. They can coexist peacefully, like iOS devs and dark mode 🌒

✅ When to absolutely go async

Use async/await when:

🧠 Final Rule of Thumb

If async makes your code:

→ Use it.

If it makes your code:

→ Maybe don’t.

🎬 Wrapping Up: You Just Mastered Swift Concurrency

Let’s take a moment to appreciate what you’ve accomplished across this 3-part journey:

🚀 In Part 1:

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

⚙️ In Part 2:

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

🧠 And here in Part 3:

🧳 What Now?

You’re no longer just “familiar” with async/await — you’ve mastered it.

So what’s next?

🎉 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