SwiftConcurrencyasync/awaitiOS

Modern concurrency in Swift: async/await, Task, actors and Sendable

Concurrency is one of those topics that feels scary until someone explains it well. Before Swift 5.5 we wrote async code with nested completion handlers, DispatchQueue everywhere, and threading bugs that were impossible to reproduce. Modern Swift concurrency changed that: today async code reads almost like sequential code, and the compiler protects you from data races.

In this guide we go from the basics to the advanced pieces you need to write correct concurrency in modern Swift: async/await, Task, structured concurrency, actor, @MainActor and Sendable.

The problem it solves

An app can’t freeze while it waits for a download or a disk query to finish. That work must run “in the background” while the main thread keeps serving the interface. The classic approach was completion handlers:

func loadProfile(id: Int, completion: @escaping (Result<Profile, Error>) -> Void) {
    downloadUser(id) { userResult in
        switch userResult {
        case .success(let user):
            downloadAvatar(user.avatarURL) { avatarResult in
                // ... and we keep nesting
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

This is the famous “pyramid of doom”: hard to read, hard to handle errors, and very easy to forget calling completion on some path. Modern concurrency removes all of it.

async / await — the foundation

A function marked async is a function that can suspend: it pauses its execution while it waits for a result and frees the thread to do something else. When the result arrives, the function resumes right where it left off.

To call an async function you use await, which marks the suspension point:

func loadProfile(id: Int) async throws -> Profile {
    let user   = try await downloadUser(id)
    let avatar = try await downloadAvatar(user.avatarURL)
    return Profile(user: user, avatar: avatar)
}

Compare this with the completion-handler version: it reads top to bottom, error handling is a plain try, and there’s no way to “forget” to return the result. await doesn’t block the thread — it releases it while it waits.

Key rules:

  • You can only use await inside an asynchronous context (another async function or a Task).
  • async and throws combine: async throws is standard for work that can fail.
  • Order is preserved: in the example, the avatar downloads after the user, because one depends on the other.

Task — the bridge from synchronous code

So how do you call an async function from a context that isn’t async, like a button action in SwiftUI? With Task. A Task creates a new asynchronous context and runs the work:

Button("Load") {
    Task {
        do {
            let profile = try await loadProfile(id: 42)
            self.profile = profile
        } catch {
            self.error = error
        }
    }
}

The Task inherits the context where it’s created (priority and actor). You can also cancel it: keep the reference and call cancel(). Inside the async work, check Task.isCancelled or use try Task.checkCancellation() to bail out cooperatively.

let task = Task {
    for page in 1...100 {
        try Task.checkCancellation()   // throws if cancelled
        await process(page)
    }
}
// later…
task.cancel()

Structured concurrency — doing several things at once

So far everything was sequential. But often you want to launch tasks in parallel and wait for all of them. That’s what structured concurrency is for.

async let

When you know up front how many parallel tasks there are, async let is the most direct:

func loadDashboard() async throws -> Dashboard {
    async let user  = downloadUser(id)
    async let news  = downloadNews()
    async let weather = downloadWeather()

    // All three downloads are already running in parallel.
    // await waits for all three:
    return try await Dashboard(user: user, news: news, weather: weather)
}

The three downloads start at once; the await on return waits for all three to finish. If each takes 1 second, the total is ~1 second, not 3.

TaskGroup

When the number of tasks is dynamic (say, downloading N images), use a TaskGroup:

func downloadImages(_ urls: [URL]) async throws -> [Image] {
    try await withThrowingTaskGroup(of: Image.self) { group in
        for url in urls {
            group.addTask { try await downloadImage(url) }
        }
        var images: [Image] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

The beauty of structured concurrency is its name: it’s structured. If a child task fails or the parent is cancelled, the other children are cancelled automatically. No orphan tasks running around.

Actors — goodbye to data races

A data race happens when two threads access the same mutable memory at the same time and at least one writes. They’re the source of the hardest concurrency bugs to catch. An actor eliminates them at the root: it serializes access to its state, guaranteeing that only one task touches its insides at a time.

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func total() -> Int {
        value
    }
}

From the outside, an actor behaves almost like a class, but accessing its state is asynchronous (because you might have to wait your turn):

let counter = Counter()
await counter.increment()
let total = await counter.total()

That await is the key: the compiler forces you to wait, and the actor makes sure there are no two concurrent accesses. Goodbye to manual locks and data races.

@MainActor — the UI always on the main thread

Anything that touches the interface must run on the main thread. @MainActor is a global actor that represents that thread. Annotate a class, a function, or a property with @MainActor and you guarantee that code runs on the main thread:

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var profile: Profile?

    func load(id: Int) async {
        // This runs on the main thread, safe to update the UI.
        profile = try? await loadProfile(id: id)
    }
}

In SwiftUI, views are already isolated to @MainActor, so updating @State from a Task inside a view is safe. When you need to jump to the main thread manually:

await MainActor.run {
    self.label.text = "Done"
}

Sendable — safety across concurrency boundaries

When a value crosses from one task or actor to another, Swift needs to guarantee it can be transferred safely. The Sendable protocol marks exactly that: types that are safe to share across concurrency contexts.

  • Value types with Sendable members (structs of Int, String, etc.) are Sendable automatically.
  • Mutable classes are not Sendable by default (they could hold shared mutable state).
  • You can mark an immutable class as Sendable if you guarantee it’s safe.
struct User: Sendable {   // struct of Sendable values → Sendable for free
    let id: Int
    let name: String
}

final class Cache: Sendable {   // immutable class, safe to share
    let capacity: Int
    init(capacity: Int) { self.capacity = capacity }
}

With Swift 6’s strict concurrency mode, the compiler warns you if you try to pass a non-Sendable type across a concurrency boundary. It’s annoying at first, but it’s exactly what prevents data races at compile time.

AsyncSequence — a stream of values over time

Just as Sequence produces values one after another, AsyncSequence produces asynchronous values you can iterate with for await:

for await notification in center.notifications {
    handle(notification)
}

It’s ideal for streams: events, messages from a WebSocket, lines from a large file. And like everything in modern concurrency, it respects cancellation.

Common mistakes to avoid

  • Blocking with await thinking it’s synchronous. await doesn’t block the thread; it suspends it. Don’t use it inside tight loops expecting it to “just wait.”
  • Creating loose Task { } without handling cancellation. If the view goes away, those tasks keep running. Keep the reference or use .task { } in SwiftUI, which cancels on its own.
  • Updating the UI off the @MainActor. If you see “publishing changes from background threads” warnings, you’re missing main-actor isolation.
  • Ignoring Sendable warnings. Don’t silence them with @unchecked Sendable lightly; they usually point to a real data race.

A complete example

To see it all together, here’s an example ready to paste into Xcode. Tapping the button launches a Task that “works” for 1.5 seconds with Task.sleep (without blocking the interface) and then updates the state. Notice how the view stays responsive in the meantime, and how updating @State from the Task is safe because the view is isolated to the @MainActor.

import SwiftUI

struct ContentView: View {
    @State private var status = "Tap to load"
    @State private var loading = false

    var body: some View {
        VStack(spacing: 24) {
            Text(status)
                .font(.title3).bold()

            if loading {
                ProgressView()
            }

            Button("Load data") {
                loading = true
                status = "Loading…"
                Task {
                    try? await Task.sleep(for: .seconds(1.5))
                    status = "✅ Data ready"
                    loading = false
                }
            }
            .buttonStyle(.borderedProminent)
            .disabled(loading)
        }
        .padding()
    }
}

Summary

  • async/await: write async code that reads like sequential code. await suspends, it doesn’t block.
  • Task: the bridge from synchronous code into the async world; supports cooperative cancellation.
  • Structured concurrency (async let, TaskGroup): launch work in parallel with automatic cancellation of child tasks.
  • actor: serializes access to mutable state and eliminates data races by design.
  • @MainActor: guarantees UI code runs on the main thread.
  • Sendable: the compiler verifies at compile time what’s safe to cross between concurrency contexts.

Modern Swift concurrency doesn’t just make code more readable: it moves an entire class of runtime bugs to compile time. Fighting the Sendable warnings at first is worth it; in exchange, you get concurrent code that’s correct by construction.