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)
| Pitfall | Problem | Solution |
|---|---|---|
| Blocking main thread | Using Task but blocking with semaphores | Use proper async/await throughout |
| Unstructured task leaks | Creating Task without storing/canceling | Use structured concurrency or manage lifecycle |
| Continuation misuse | Resuming continuation multiple times | Use withCheckedContinuation to catch errors |
| Actor deadlock | Calling back into same actor synchronously | Design APIs to avoid circular calls |
| Ignoring cancellation | Long operations don’t check Task.isCancelled | Add cancellation checks in loops |
Further Reading
If you want to go deeper, these are worth your time:
Apple Docs:
- Concurrency Overview
- Calling Asynchronous Functions in Parallel
- Tasks and Task Groups
- Actors
- Sendable Types
WWDC Sessions (still the best way to learn this stuff):
- Meet async/await in Swift (WWDC21)
- Explore structured concurrency in Swift (WWDC21)
- Protect mutable state with Swift actors (WWDC21)
- Swift concurrency: Behind the scenes (WWDC21)
- Visualize and optimize Swift concurrency (WWDC22)
Migration: