Back to Blog

Swift Concurrency in Practice: Async/Await and Actors

Practical Swift concurrency patterns for async/await, actors, MainActor, Sendable, cancellation, and building responsive iOS apps without data races.

5 min read

If you’ve shipped an iOS app, you already know the three pain points of async work:

  • waiting on the network without blocking the UI
  • coordinating multiple requests without turning the code into spaghetti
  • keeping shared state from being corrupted when tasks overlap

Swift concurrency solves all three - but only if you treat it as a design constraint, not just new syntax.

Here’s the mental model and the patterns I actually trust in production.

Two questions that prevent most mistakes

  1. Where does this code run? (main actor vs background executor)
  2. Who owns this mutable state? (an actor, or confined to one actor)

If you can answer both, you’re usually fine.

async/await: the shape of your code matches reality

struct User: Decodable {
    let id: String
    let name: String
}

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)
}

Call sites read like normal control flow:

func load() async {
    do {
        let user = try await fetchUser(id: "123")
        print(user.name)
    } catch {
        print("Failed:", error)
    }
}

Crossing the boundary from UIKit / callbacks

UIKit targets and delegates are synchronous. Use Task as your boundary:

@IBAction func refreshTapped(_ sender: UIButton) {
    Task { [weak self] in
        guard let self else { return }
        await self.refresh()
    }
}

Inside that task you can await, propagate cancellation, and use task groups.

Concurrency vs parallelism

Concurrency is overlap; parallelism is simultaneous execution.

Swift lets you express “these can overlap”, and the runtime decides how many threads to burn.

async let for a small fixed set

async let user = fetchUser(id: userId)
async let preferences = fetchPreferences()
async let entitlement = fetchEntitlement()

let (u, p, e) = try await (user, preferences, entitlement)

Task groups for “N things”

func fetchUsers(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
    }
}

Throttle or you’ll DDOS yourself

func downloadAll(_ urls: [URL], maxConcurrent: Int = 6) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        var results: [Data] = []
        var iterator = urls.makeIterator()

        for _ in 0..<maxConcurrent {
            if let url = iterator.next() {
                group.addTask { try await download(url) }
            }
        }

        for try await data in group {
            results.append(data)
            if let url = iterator.next() {
                group.addTask { try await download(url) }
            }
        }

        return results
    }
}

Actors: shared mutable state goes here

If multiple tasks read/write the same data, don’t fight it: use an actor.

actor TokenStore {
    private var token: String?

    func read() -> String? { token }
    func write(_ newValue: String?) { token = newValue }
}

Actor reentrancy: the subtle part

Actor methods can suspend at await points. That means other work can run on the actor between suspension points.

If you need atomic read-modify-write, keep it synchronous inside the actor.

MainActor: make UI state boring

My default rule: UI-facing state lives on @MainActor.

@MainActor
@Observable
final class ProfileModel {
    private(set) var user: User?
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    func load(userId: String) async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            user = try await fetchUser(id: userId)
        } catch {
            errorMessage = "Could not load profile."
        }
    }
}

Sendable: treat warnings as design feedback

When you pass values across concurrency boundaries, the compiler wants proof they’re safe.

struct Settings: Sendable {
    let region: String
    let maxItems: Int
}

If you’re tempted to slap @unchecked Sendable on something mutable: stop and redesign first. If you must do it, isolate the mutation behind a lock/actor and add tests.

Cancellation: make your app feel instant

Cancellation is cooperative - especially in loops.

func index(_ items: [Item]) async throws -> [IndexEntry] {
    var out: [IndexEntry] = []
    out.reserveCapacity(items.count)

    for item in items {
        try Task.checkCancellation()
        out.append(await indexOne(item))
    }

    return out
}

SwiftUI cancels .task when the view goes away. That’s not a bug. Lean into it.

A couple of practical recipes

Debounced search (no Combine)

@MainActor
@Observable
final class SearchModel {
    var query = "" {
        didSet { pending?.cancel(); schedule() }
    }

    private(set) var results: [Result] = []
    private var pending: Task<Void, Never>?

    private func schedule() {
        pending = Task {
            try? await Task.sleep(for: .milliseconds(250))
            guard !Task.isCancelled else { return }
            results = (try? await api.search(query)) ?? []
        }
    }
}

Timeout wrapper

func withTimeout<T>(
    seconds: Int,
    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(for: .seconds(seconds))
            throw TimeoutError()
        }

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

Takeaways

  • Use async/await for clarity and cancellation.
  • Use async let and task groups for safe parallel work.
  • Put shared mutable state behind actors.
  • Keep UI state on @MainActor.
  • Treat Sendable warnings as feedback, not noise.