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.
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
- Where does this code run? (main actor vs background executor)
- 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/awaitfor clarity and cancellation. - Use
async letand task groups for safe parallel work. - Put shared mutable state behind actors.
- Keep UI state on
@MainActor. - Treat
Sendablewarnings as feedback, not noise.