Concurrency

Concurrency lets your app do several things at once — fetch data from the network while keeping the screen responsive, or crunch images in parallel. Swift bakes this into the language with async/await, structured tasks, and actors. In Swift 6, the compiler goes one step further: strict concurrency is on by default, so whole classes of data-race bugs are caught while you type, not at 2 a.m. in production.

The problem concurrency solves

Some work takes time: downloading a file, reading a database, resizing a photo. If you do that work on the main thread, the UI freezes until it finishes. The old fix was completion handlers — nested closures that ran "later" and were easy to get wrong (forgotten calls, double calls, errors on the wrong thread). Swift's concurrency model replaces that pyramid of callbacks with code that reads top-to-bottom but runs asynchronously.

// The old way: nested completion handlers
fetchUser(id: 42) { user in
    fetchAvatar(user.avatarURL) { image in
        resize(image) { thumbnail in
            DispatchQueue.main.async {
                imageView.image = thumbnail   // easy to forget the hop to main
            }
        }
    }
}

Callback nesting — hard to read, easy to get the threading wrong.

async/await

A function marked async can suspend: it can pause, let the thread do other work, and resume later. You call an async function with await, which marks the spot where a suspension may happen. The function that calls it must itself be async (or run inside a task).

func loadThumbnail(id: Int) async throws -> Image {
    let user = try await fetchUser(id: id)
    let image = try await fetchAvatar(user.avatarURL)
    return await resize(image)
}

The same logic as the callback pyramid — but linear and readable.

async and throws compose: write async throws in the signature, and try await at the call site. Each await is a possible suspension point — after resuming, you may even be on a different thread, so never assume thread identity across an await.

Importantly, await does not block a thread. While a task is suspended, the underlying thread is free to run other tasks — which is what keeps the UI smooth without spawning piles of OS threads.

Calling async functions from sync code

You can't await from an ordinary synchronous function. To cross the boundary — say from a SwiftUI button action — wrap the call in a Task:

Button("Load") {
    Task {
        let image = try? await loadThumbnail(id: 42)
        // update state here
    }
}

Async sequences

Just as for-in walks a regular collection, for await walks an asynchronous sequence — values that arrive over time, such as lines streaming from a file or events from a socket. The loop suspends between elements and resumes when the next one is ready.

let url = URL(string: "https://example.com/log.txt")!
for try await line in url.lines {
    print(line)            // prints each line as it streams in
    if line.contains("ERROR") { break }
}

Iterating an async sequence with for try await.

You build your own by conforming to AsyncSequence, but most of the time you'll consume ones the system gives you (URL.lines, NotificationCenter.notifications, AsyncStream).

Running async code in parallel with async let

Awaiting calls one after another runs them sequentially. When the calls are independent, that's wasted time. async let starts a child task immediately and lets you await its result later — so several pieces of work overlap.

func loadProfile() async throws -> Profile {
    async let user = fetchUser(id: 42)
    async let posts = fetchPosts(userID: 42)
    async let friends = fetchFriends(userID: 42)

    // all three downloads run concurrently; we wait for all here
    return try await Profile(user: user, posts: posts, friends: friends)
}

Three independent requests overlap instead of running back-to-back.

Each async let is a child of the current task. If you never await one, Swift cancels and awaits it implicitly when the scope ends — no leaked work.

Tasks and task groups

A task is a unit of asynchronous work. async let is convenient when you know the number of child tasks up front. When the count is dynamic — process every item in an array — use a task group, which lets you add children in a loop and collect their results.

func loadThumbnails(ids: [Int]) async throws -> [Int: Image] {
    try await withThrowingTaskGroup(of: (Int, Image).self) { group in
        for id in ids {
            group.addTask {                    // each child runs concurrently
                (id, try await loadThumbnail(id: id))
            }
        }
        var result: [Int: Image] = [:]
        for try await (id, image) in group {
            result[id] = image             // gather results as they finish
        }
        return result
    }
}

A task group fans out a variable number of child tasks and gathers their results.

Structured vs. unstructured tasks

The examples above are structured concurrency: child tasks (async let, task-group children) live inside a clear scope. The parent can't return until its children finish, errors propagate up, and cancellation flows down automatically. The shape of your code mirrors the shape of the concurrency.

Sometimes you need work whose lifetime isn't tied to the current scope — for example, kicking off a download from a button tap. That's an unstructured task, created with Task { }. You get a handle you can await or cancel later, but you're responsible for managing it.

// Unstructured: inherits actor context & priority, runs independently
let handle = Task {
    try await loadThumbnail(id: 42)
}

// Detached: does NOT inherit context — use sparingly
let detached = Task.detached(priority: .background) {
    rebuildSearchIndex()
}

let image = try await handle.value   // await the result later
AspectStructured (async let / group)Unstructured (Task / Task.detached)
LifetimeBound to enclosing scopeIndependent of scope
CancellationPropagates automaticallyManual (handle.cancel())
Error propagationBubbles up to parentSurfaces only when you await .value
Context inheritanceInheritsTask inherits; Task.detached does not

Task cancellation

Cancellation in Swift is cooperative: cancelling a task doesn't forcibly stop it — it sets a flag, and your code is expected to check that flag and wind down. Many built-in async APIs check for you and throw CancellationError.

func process(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation()   // throws if cancelled
        if Task.isCancelled { return }   // or check the Bool and bail quietly
        await handle(item)
    }
}

let job = Task { try await process(bigList) }
job.cancel()   // requests cancellation; the task observes it at the next check

Cancellation is cooperative — check isCancelled or call checkCancellation().

Tip: In structured concurrency, cancelling a parent task automatically cancels all of its children. You rarely cancel individual child tasks by hand.

Actors and isolation

Sharing mutable state between concurrent tasks is the classic source of data races. An actor solves this: it's a reference type that protects its own mutable state by guaranteeing only one task touches that state at a time. Its stored properties and methods are actor-isolated — reachable from outside only with await.

actor BankAccount {
    private var balance = 0

    func deposit(_ amount: Int) {
        balance += amount          // safe: only one task runs this at a time
    }

    func currentBalance() -> Int { balance }
}

let account = BankAccount()
await account.deposit(100)          // cross-actor call needs await
let total = await account.currentBalance()

An actor serializes access to its mutable state, eliminating races by construction.

Inside the actor's own methods you reach balance directly, without await, because you're already isolated to that actor. From outside, every access is a potential suspension point, hence await.

Watch out: Don't reintroduce a race across an await. Between reading and writing an actor's value, another task may have run. Prefer a single isolated method that does the read-modify-write atomically rather than awaiting a getter, computing, then awaiting a setter.

The main actor and @MainActor

UIKit and SwiftUI must be touched only on the main thread. Swift models the main thread as a global actor called MainActor. Annotate a type, method, or property with @MainActor to guarantee it runs there — the compiler enforces it, so you never need a manual DispatchQueue.main.async hop again.

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var name = ""

    func load() async {
        let user = try? await fetchUser(id: 42)   // runs off-main during the await
        name = user?.name ?? "Unknown"          // back on main — safe to update UI state
    }
}

Everything in this class is guaranteed to run on the main actor.

Note how the await suspends and the network call happens off the main actor, but assigning name resumes back on main — the annotation makes that hop automatic and checked.

Sendable types

For the compiler to prove safety, it needs to know which values are safe to pass between concurrency domains (across tasks, into an actor, over an await). Those values conform to the Sendable protocol. Value types built from Sendable members are Sendable automatically; reference types must justify it.

// Value type with Sendable members → automatically Sendable
struct User: Sendable {
    let id: Int
    let name: String
}

// A class is Sendable only if it's safe — e.g. immutable + final
final class Config: Sendable {
    let apiKey: String
    init(apiKey: String) { self.apiKey = apiKey }
}

// Closures crossing concurrency boundaries are marked @Sendable
func run(_ work: @Sendable @escaping () -> Void) { /* ... */ }

Actors are implicitly Sendable; mutable classes that aren't will raise a compiler error when shared.

Strict concurrency in Swift 6

The headline change in Swift 6 is that data-race safety is checked at compile time, by default. The compiler tracks isolation and Sendable conformance across every task and actor boundary. If a non-Sendable value could be touched from two places at once, you get an error — not a crash discovered later.

Note: Think of strict concurrency as a type system for threads. Just as Swift's optionals turned "nil crashes" into compile-time questions, strict concurrency turns "data races" into compile-time questions.

Wrapping up

Swift concurrency gives you readable asynchronous code (async/await), safe parallelism (async let, task groups), clear lifetimes (structured tasks with cooperative cancellation), and race-free shared state (actors, @MainActor, Sendable). In the Swift 6 era, the compiler verifies all of it for you. Start by writing linear async code, reach for actors when you share mutable state, and let the compiler guide you toward safety.