Concurrency: actors, tasks and MainActor
Structured concurrency with task groups, actor isolation that removes data races, Sendable checking, and the rules that decide when SwiftUI work runs on the main thread.
Tasks and structured concurrency
func loadAll(_ ids: [Int]) async throws -> [Summary] {
try await withThrowingTaskGroup(of: Summary.self) { group in
for id in ids {
group.addTask { try await fetchSummary(id) }
}
var results: [Summary] = []
for try await summary in group {
results.append(summary)
}
return results
}
}
func loadPair() async throws -> (Profile, [Order]) {
async let profile = fetchProfile()
async let orders = fetchOrders()
return try await (profile, orders)
}- If the parent task is cancelled, child tasks in the group are cancelled too — that is what makes it structured.
async letfor a fixed set of concurrent calls, a task group when the count is dynamic.- Results arrive in completion order, not submission order. Sort at the end if the order matters.
- Never use
Task.detachedto escape actor isolation unless you truly want no inherited context.
Actors and isolation
actor ImageCache {
private var storage: [URL: Data] = [:]
func data(for url: URL) -> Data? { storage[url] }
func insert(_ data: Data, for url: URL) {
storage[url] = data
}
}
// actor state is only reachable through await
let cache = ImageCache()
await cache.insert(data, for: url)
let hit = await cache.data(for: url)
@MainActor
final class ViewState {
var title = ""
func update(_ text: String) { title = text } // already on the main actor
}| Isolation | Guarantees | Cost |
|---|---|---|
@MainActor | Runs on the main thread | Blocks the UI if the work is heavy |
actor | Serialised access to its state | Every call is await and may suspend |
Sendable struct | Safe to pass across boundaries | Must be immutable or use value semantics |
nonisolated | No isolation, callable anywhere | Cannot touch isolated state directly |
What actually breaks
// Value types composed of Sendable members are Sendable automatically
struct User: Sendable {
let id: Int
let name: String
}
// A class with mutable state is not, and the compiler will say so
// in Swift 6 language mode this is a hard error, not a warning:
final class TokenStore: @unchecked Sendable {
private let lock = NSLock()
private var token: String?
func set(_ value: String) { lock.lock(); defer { lock.unlock() }; token = value }
func get() -> String? { lock.lock(); defer { lock.unlock() }; return token }
}⚠️
@unchecked Sendable is a promise to the compiler that you have handled synchronisation yourself. Break that promise and you get races the type checker can no longer catch — use it only around a lock or an actor, and never around a plain mutable var.FAQ
Why does my UI update not appear?
You are mutating state from a background context. Mark the view model or the mutation method
@MainActor, or hop back with await MainActor.run { ... }.Do I need <code>Task.detached</code> for expensive work?
Usually not. A plain
Task { } inherits priority and cancellation from its caller, and combined with an actor or @MainActor model it is easier to reason about. Detached tasks lose that context.Related
Networking with URLSession and async/await Swift essentials for iOS
Last refreshed 2026-09-18.