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 let for 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.detached to 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
}
IsolationGuaranteesCost
@MainActorRuns on the main threadBlocks the UI if the work is heavy
actorSerialised access to its stateEvery call is await and may suspend
Sendable structSafe to pass across boundariesMust be immutable or use value semantics
nonisolatedNo isolation, callable anywhereCannot 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.

Networking with URLSession and async/await Swift essentials for iOS

Last refreshed 2026-09-18.