9 min read
Swift Async/Await Best Practices
Swift Async/Await Best Practices

Swift’s concurrency model has been around for a few years now, and honestly? It’s one of the best things to happen to iOS development in a long time. But it also introduced a whole new category of ways to shoot yourself in the foot.

I’ve spent a lot of time working with async/await, actors, TaskGroup, and all the rest — and I’ve hit most of the sharp edges so you don’t have to. This is the guide I wish I’d had when I started adopting structured concurrency in production code.


The Basics (Quick Refresher)

If you’re already comfortable with async/await syntax, skip ahead. But for completeness:

Marking Functions as Async

func fetchUser(id: String) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

Nothing wild here — async means “this function can suspend,” and throws means it can fail. The compiler enforces both at the call site.

Calling Async Functions

// From another async context
let user = try await fetchUser(id: "123")

// From synchronous code - use Task
Task {
    let user = try await fetchUser(id: "123")
}

That Task { } bridge is where most people start — and where a lot of bad habits form. More on that in a second.


Best Practices

1. Prefer Structured Concurrency (Seriously)

This is the single most important habit to build. Prefer async let and TaskGroup over loose Task creation whenever possible.

// ✅ Good - Structured, automatic cancellation propagation
async let user = fetchUser(id: "123")
async let posts = fetchPosts(userId: "123")
let (userData, userPosts) = try await (user, posts)

// ⚠️ Avoid when possible - Unstructured, manual lifetime management
let task = Task {
    await fetchUser(id: "123")
}

Why does this matter? Structured concurrency gives you automatic cancellation propagation, proper error handling, and predictable lifetimes. Unstructured Task blocks are basically fire-and-forget — and “forget” is doing a lot of heavy lifting there. They’re the DispatchQueue.global().async of the new world, and we all know how well that went.

2. Handle Cancellation (Don’t Be That Developer)

Long-running operations should check for cancellation. Your users will thank you when they navigate away and your app isn’t still crunching through a 500-item array in the background.

func processItems(_ items: [Item]) async throws {
    for item in items {
        // Check cancellation before expensive work
        try Task.checkCancellation()
        await process(item)
    }
}

// Or handle gracefully without throwing
func processItems(_ items: [Item]) async -> [Result] {
    var results: [Result] = []
    for item in items {
        if Task.isCancelled { break }
        results.append(await process(item))
    }
    return results
}

The difference between checkCancellation() (throws) and isCancelled (returns bool) is about control. Use the throwing version when you want to bail out entirely. Use the bool when you want to clean up gracefully.

3. Use TaskGroup for Dynamic Concurrency

async let is great when you know exactly how many concurrent operations you need. When you don’t — say, fetching an array of users — TaskGroup is your friend:

func fetchAllUsers(ids: [String]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }

        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

One thing to note: results come back in completion order, not submission order. If ordering matters, you’ll need to handle that yourself (tuple the index with the result, sort after, etc.).

4. Isolate Mutable State with Actors

Actors are one of my favorite additions to Swift. They replace a whole class of DispatchQueue synchronization patterns with something the compiler can actually verify:

actor UserCache {
    private var cache: [String: User] = [:]

    func user(for id: String) -> User? {
        cache[id]
    }

    func store(_ user: User, for id: String) {
        cache[id] = user
    }
}

No locks. No queues. No “did I remember to dispatch to the right queue?” The compiler won’t even let you access actor state without await. It’s beautiful.

5. Use @MainActor for UI Updates

You can annotate an entire class or individual properties/methods:

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false

    func loadUser() async {
        isLoading = true
        defer { isLoading = false }

        user = try? await fetchUser(id: "123")
    }
}

// Or isolate specific methods
class ProfileViewModel: ObservableObject {
    @MainActor @Published var user: User?

    func loadUser() async {
        let fetchedUser = try? await fetchUser(id: "123")
        await MainActor.run {
            self.user = fetchedUser
        }
    }
}

My preference: just slap @MainActor on the whole view model. It’s simpler, and the performance cost of hopping to main for a property write is negligible compared to the bugs you’ll avoid.

6. Watch Out for Actor Reentrancy

This one bites people. Actor methods are not atomic across suspension points. State can change while you’re awaiting something:

actor BankAccount {
    var balance: Int = 100

    // ⚠️ Potential issue - balance may change during await
    func transferUnsafe(amount: Int, to other: BankAccount) async {
        if balance >= amount {
            balance -= amount           // State captured
            await other.deposit(amount) // Suspension point
            // balance may have changed here!
        }
    }

    // ✅ Better - capture state before suspension
    func transferSafe(amount: Int, to other: BankAccount) async -> Bool {
        guard balance >= amount else { return false }
        balance -= amount
        await other.deposit(amount)
        return true
    }
}

The rule of thumb: do all your state mutations before any await. Once you suspend, assume the world has changed.

7. Bridge Legacy Code with Continuations

Got old callback-based APIs? Wrap them:

func fetchUserLegacy(completion: @escaping (Result) -> Void) {
    // Legacy callback-based API
}

func fetchUser() async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        fetchUserLegacy { result in
            continuation.resume(with: result)
        }
    }
}

The one rule you cannot break: resume exactly once. Not zero times (your caller hangs forever). Not twice (your app crashes). Use withCheckedContinuation during development — it’ll loudly tell you if you mess this up. Switch to withUnsafeContinuation in production if you need the tiny performance win.

8. Set Task Priorities Intentionally

Task(priority: .userInitiated) {
    await loadVisibleContent()
}

Task(priority: .background) {
    await prefetchNextPage()
}

Task.detached(priority: .utility) {
    await syncAnalytics()
}

Don’t just use the default for everything. If the user is waiting for something, make it .userInitiated. If it’s housekeeping, make it .background. The system actually uses these to schedule work — they’re not just suggestions.


Patterns You’ll Actually Use

Sequential vs Parallel Execution

This is the kind of thing that was annoyingly hard with completion handlers and is now trivially clear:

// Sequential - each awaits the previous
let user = try await fetchUser(id: "123")
let posts = try await fetchPosts(userId: user.id)
let comments = try await fetchComments(postIds: posts.map(\.id))

// Parallel - independent operations run concurrently
async let user = fetchUser(id: "123")
async let notifications = fetchNotifications()
async let settings = fetchSettings()
let (u, n, s) = try await (user, notifications, settings)

If operations don’t depend on each other, run them in parallel. It’s free performance.

Timeout Pattern

Swift doesn’t have a built-in timeout for async operations (yet?), but you can build one with TaskGroup:

func fetchWithTimeout<T>(
    seconds: Double,
    operation: @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask {
            try await operation()
        }
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw TimeoutError()
        }

        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

Whichever finishes first wins. The other gets cancelled. Elegant? Debatable. Effective? Absolutely.

Retry Pattern

Network calls fail. This is not a controversial statement. Here’s a retry wrapper:

func withRetry<T>(
    maxAttempts: Int = 3,
    delay: Duration = .seconds(1),
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?

    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch {
            lastError = error
            if attempt < maxAttempts {
                try await Task.sleep(for: delay * attempt)
            }
        }
    }

    throw lastError!
}

The multiplicative delay (delay * attempt) gives you basic exponential backoff. Good enough for most cases, and way better than just hammering the server three times in a row.


Common Pitfalls (Learn from My Mistakes)

PitfallProblemSolution
Blocking main threadUsing Task but blocking with semaphoresUse proper async/await throughout
Unstructured task leaksCreating Task without storing/cancelingUse structured concurrency or manage lifecycle
Continuation misuseResuming continuation multiple timesUse withCheckedContinuation to catch errors
Actor deadlockCalling back into same actor synchronouslyDesign APIs to avoid circular calls
Ignoring cancellationLong operations don’t check Task.isCancelledAdd cancellation checks in loops

Further Reading

If you want to go deeper, these are worth your time:

Apple Docs:

WWDC Sessions (still the best way to learn this stuff):

Migration: