Skip to content
Commitwise
Menu
iOS Development

Swift Concurrency: actors and Sendable

Run work in parallel with async let and task groups, protect shared state with actors, keep UI code on the main actor, and understand Sendable and the Swift 6 data-race checks.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A crash report: EXC_BAD_ACCESS inside Dictionary, once in every few thousand sessions, never in the simulator. The cause is a cache that two threads wrote to at the same moment: a data race. Bugs like this used to be found by luck. Swift 6 finds them at compile time.

This tutorial covers the pieces that make that possible: async let and task groups for running work in parallel, actorsActorA Swift type that protects its mutable state by running only one of its methods at a time.Open in glossary → for protecting shared state, the main actor for UI, and SendableSendableA Swift protocol that marks values as safe to pass between concurrently running tasks and actors.Open in glossary →, the compiler’s way of checking what can safely cross between them.

New to some of these? Open what you need, or skip ahead.

Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:What does asynchronous mean?

Synchronous code does one thing at a time and waits for each step to finish. Asynchronous code starts something slow (a download), lets other work continue in the meantime, and continues when the result is ready.

Swift (async/await), Kotlin (suspend functions in coroutines) and Java (virtual threads, futures) each have their own way to write asynchronous code that still reads top to bottom, like normal code. The waiting doesn’t freeze the app.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data.
Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, and Swift’s let and structs are the usual tools.

Part Not started

Tasks, actors and the main actor

Think of a busy kitchen:

  • Tasks are cooks working at the same time. await is a cook waiting for the oven: while one waits, the others keep working, so nothing is blocked.
  • An actor is a station with one cook at a time, like the till. Anyone can ask the till for something, but requests are handled one by one, so two people can never change the same order at once. That’s how actors protect their data.
  • The main actor is the waiter who talks to customers. Only they may change what the customer sees, which is why all UI code runs on the main actor.
  • Sendable is the label on a dish that says “safe to hand to another station”: a copy that nobody else will change, or something that protects itself.

Swift’s concurrency model is built on isolation domains: each actor (including the @MainActor global actor) owns its mutable state, and code outside it must await to interact, which serializes access without locks. Values crossing domains must be Sendable, or be provably not used again by the sender (region-based isolation, sending). In the Swift 6 language mode, violations are compile-time errors, which eliminates data races in checked code.

The parts that still bite are semantic rather than syntactic: actor reentrancy (state can change across any await), unstructured tasks that outlive their callers or swallow errors, @unchecked Sendable promises that nothing verifies, and choosing isolation deliberately in Swift 6.2, where app targets can default to @MainActor. All in the Detailed version.

actor ThumbnailCache {
    private var thumbnails: [URL: Data] = [:]

    func thumbnail(for url: URL) -> Data? {
        thumbnails[url]
    }

    func store(_ data: Data, for url: URL) {
        thumbnails[url] = data
    }
}

func loadThumbnails(_ urls: [URL], cache: ThumbnailCache) async {
    await withTaskGroup(of: Void.self) { group in
        for url in urls {
            group.addTask {                                   // runs in parallel with the others
                if await cache.thumbnail(for: url) != nil { return }
                guard let data = try? await URLSession.shared.data(from: url).0 else { return }
                await cache.store(data, for: url)             // one at a time, inside the actor
            }
        }
    }
}

What to notice:

  • actor instead of class. The dictionary can only be touched by code inside the actor, and the actor runs that code one call at a time.
  • await cache.store(...) from outside: calling into an actor may have to wait for its turn, so it’s always await.
  • withTaskGroup starts one child task per URL. They run in parallel, and the group waits until all of them finish before loadThumbnails returns.

The race the compiler catches

Here’s the same cache as a plain class. In the Swift 5 language mode it compiles, and it crashes occasionally. Which line is the problem?

Spot the bugA cache that crashes once in a while

Tap the line with the bug.

final class ThumbnailCache {
    private var thumbnails: [URL: Data] = [:]
    func store(_ data: Data, for url: URL) { thumbnails[url] = data }
}

let cache = ThumbnailCache()
await withTaskGroup(of: Void.self) { group in
    for url in urls {
        group.addTask {
            if let data = try? await URLSession.shared.data(from: url).0 {
                cache.store(data, for: url)
            }
        }
    }
}

Parallel, not one after another

await waits for one thing at a time. When two loads don’t depend on each other, start both first and wait for both together with async let:

Predict the output

Each load takes one second. How many seconds does the measured block take?

func load(_ name: String) async -> String {
    try? await Task.sleep(for: .seconds(1))
    return name
}

let elapsed = await ContinuousClock().measure {
    async let profile = load("profile")
    async let orders = load("orders")
    _ = await (profile, orders)
}
print(elapsed.components.seconds)

Use async let for a fixed number of independent loads (a screen that needs a profile and its orders), and a task group when the number of tasks depends on the data (a thumbnail per URL).

Reentrancy: actors aren’t transactions

An actor runs one piece of its code at a time, but every await inside an actor method is a suspension point where other calls can run. State you read before an await may be different after it:

Predict the output

Two calls to incrementSlowly run concurrently. What gets printed?

actor Counter {
    private(set) var value = 0

    func incrementSlowly() async {
        let current = value
        try? await Task.sleep(for: .milliseconds(100))
        value = current + 1
    }
}

let counter = Counter()
async let first: Void = counter.incrementSlowly()
async let second: Void = counter.incrementSlowly()
_ = await (first, second)
print(await counter.value)

The same shape appears as duplicate work: an image cache checks images[url], misses, awaits a download, and a second request for the same URL does the same in the meantime. The fix (the exercise below) is to store the in-flight task before suspending.

Sendable in practice

  • Value types whose stored properties are all Sendable are implicitly Sendable (for non-public types). This is why value-type models cross actors for free.
  • Classes are Sendable only if final with immutable (let) Sendable properties, or isolated to a global actor (@MainActor classes are Sendable, because all access goes through the main actor).
  • sending and region isolation let you pass a non-Sendable value to another domain when the compiler can prove the sender never touches it again, which removes many false positives from Swift 5.10.
  • @unchecked Sendable is a promise the compiler can’t verify: valid only with internal synchronization (a Mutex from the Synchronization module on iOS 18+, or OSAllocatedUnfairLock), and worth a comment explaining why.
  • nonisolated(unsafe) for globals is the same kind of promise; prefer making the global a let of a Sendable type.

A token store that silences the compiler

Spot the bugA token store that silences the compiler

Tap the line with the bug.

final class TokenStore: @unchecked Sendable {
    private var token: String?

    func update(_ newToken: String) { token = newToken }
    func current() -> String? { token }
}

// Used from the API client (many concurrent requests) and the refresh task:
let tokens = TokenStore()

Deep dives

Deep diveGlobal actors beyond the main actorInterview favorite

@MainActor is a global actor: an annotation that puts functions, properties and whole types in one shared isolation domain, run by a single actor instance. You can define your own:

@globalActor actor DatabaseActor { static let shared = DatabaseActor() }

Then @DatabaseActor on a class or function confines it to that actor, so everything that touches the database is serialized and checked by the compiler, without passing an actor instance around. Use them sparingly: every call across the boundary is an await, and a few well-chosen domains are easier to reason about than many.

  • Actors serialize access, but every await inside an actor method is a suspension point: state read before it can change after it (lost updates, duplicate work). Store in-flight tasks before suspending.
  • Sendable: value types with Sendable members, final classes with immutable state, global-actor classes; sending and region isolation remove false positives. @unchecked Sendable and nonisolated(unsafe) are unverified promises: only with a lock and a comment.

Check this part

  1. What does an actor guarantee about its state?

  2. Two independent loads each take one second. How do you make them take about one second together?

  3. An actor method reads a value, awaits, then writes value + 1. Two calls run concurrently. What can happen?

  4. What does @unchecked Sendable check?

Tasks run work concurrently; an actor protects its state by handling one call at a time, so outside code must await it; Sendable marks values that are safe to hand across. Swift 6 turns data races into compile errors.

Part Not started

The main actor and UI

Anything that changes what’s on screen must happen on the main actor. SwiftUI views and their @State are main-actor isolated, and view models should be too:

@MainActor @Observable
final class OrdersModel {
    private(set) var orders: [Order] = []
    private(set) var isLoading = false

    func load() async {
        isLoading = true
        defer { isLoading = false }
        orders = (try? await api.orders()) ?? []    // the network call suspends; nothing blocks
    }
}

await api.orders() doesn’t block the main thread: the main actor is free to draw frames while the request is in flight, and the function continues on the main actor when the result arrives.

When you start work from synchronous code (a button action), you create a task, and the kind of task decides where it runs:

  • Task { … } inherits the actor of the code that creates it. Started from a view or a @MainActor model, it runs on the main actor, so it may update UI state.
  • Task.detached { … } inherits nothing: it runs off the main actor, so it must not touch UI state directly.

And many SwiftUI modifiers, like .task and .refreshable, already take an async closure that runs on the main actor, so you don’t need to create a task at all.

Practice: loading many things, but not too many at once

A gallery shows 200 thumbnails. Loading them one by one is slow; starting 200 downloads at once floods the network. Step by step:

  1. Start a task group that returns (index, data) pairs, so you can put results back in order.
  2. Start only a few tasks first, say 4.
  3. Each time one finishes, store its result and start the next URL. At most 4 run at any time.
  4. Return the results sorted by index.

The same Task rules apply to buttons. Find the problem here:

Spot the bugRefreshing from the background

Tap the line with the bug.

struct InboxView: View {
    @State private var messages: [Message] = []

    var body: some View {
        List(messages) { MessageRow(message: $0) }
            .refreshable {
                Task.detached {
                    let fresh = try await api.messages()
                    messages = fresh
                }
            }
    }
}

Exercisemedium

Thumbnails, four at a time

Write loadThumbnails(_ urls: [URL]) async -> [Data?] that downloads every URL with at most 4 downloads running at once, and returns the results in the same order as urls (nil for a failed download). If the calling task is cancelled, stop starting new downloads.

›

withTaskGroup(of: (Int, Data?).self) lets each child return its index along with the data, so order doesn’t depend on which finishes first.

›

Add the first 4 tasks, then loop with for await (index, data) in group: store the result and, if there are URLs left (and !Task.isCancelled), add the next task.

SolutionTry it yourself firstTime O(n)Space O(n)
func loadThumbnails(_ urls: [URL], maxConcurrent: Int = 4) async -> [Data?] {
    await withTaskGroup(of: (Int, Data?).self) { group in
        var results = [Data?](repeating: nil, count: urls.count)
        var next = 0

        func startNext() {
            guard next < urls.count, !Task.isCancelled else { return }
            let index = next
            let url = urls[index]
            group.addTask {
                let data = try? await URLSession.shared.data(from: url).0
                return (index, data)
            }
            next += 1
        }

        for _ in 0..<min(maxConcurrent, urls.count) { startNext() }

        for await (index, data) in group {
            results[index] = data
            startNext()                       // keep at most maxConcurrent running
        }
        return results
    }
}

The group is structured: loadThumbnails can’t return before every child finishes, and cancelling the caller cancels all children (their URLSession requests are cancelled too). Each child returns a value instead of writing to shared state, so there’s nothing to protect: no actor, no lock. That’s the cleanest kind of concurrency: tasks that only communicate through their results.

Choosing isolation

  • UI and view models: @MainActor. In Swift 6.2, app targets can make that the default (-default-isolation MainActor, the “Default Actor Isolation” build setting in new Xcode projects), so code is main-actor unless you opt out. It removes most annotations from UI code and makes the opt-outs (nonisolated, @concurrent) visible.
  • Shared mutable state used from many tasks: an actor, or a Mutex for tiny critical sections that never await (actors cost a suspension per call).
  • CPU-heavy pure functions: nonisolated (and @concurrent in Swift 6.2 when called from an actor, so they don’t run on the caller’s executor), taking and returning Sendable values.
  • Task {} vs Task.detached {}: Task {} inherits the current actor, priority and task-locals; detached inherits nothing. Both are unstructured: nothing waits for them or cancels them automatically, so store the handle when the work must be cancelled, and prefer async let, task groups, .task and async closures.
  • Cancellation is cooperative: it sets a flag. Long loops must check Task.isCancelled or call try Task.checkCancellation(); system APIs like URLSession and Task.sleep check for you.
Exercisemedium

An image loader without duplicate downloads

A feed shows avatars; the same avatar URL appears in many rows, and rows appear and disappear quickly while scrolling. Write an ImageLoader that:

  • returns cached images immediately,
  • downloads each URL at most once, even when 10 rows ask for it at the same time,
  • doesn’t keep failed downloads around, so they can be retried,
  • is safe to call from any task.
›

An actor protects the cache, but remember reentrancy: between “not in the cache” and “store the result” there’s an await where another call can arrive.

›

Store a Task<Data, Error> for each URL before awaiting it. A second caller finds the task and awaits the same one. Replace it with the result when done, and remove it on failure.

SolutionTry it yourself first
actor ImageLoader {
    private enum Entry {
        case inProgress(Task<Data, Error>)
        case ready(Data)
    }

    private var entries: [URL: Entry] = [:]
    private let session: URLSession

    init(session: URLSession = .shared) { self.session = session }

    func image(for url: URL) async throws -> Data {
        if let entry = entries[url] {
            switch entry {
            case .ready(let data):
                return data
            case .inProgress(let task):
                return try await task.value            // join the download already running
            }
        }

        let task = Task {                              // not structured: shared by every caller
            let (data, response) = try await session.data(from: url)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
            return data
        }
        entries[url] = .inProgress(task)               // stored BEFORE the first await

        do {
            let data = try await task.value
            entries[url] = .ready(data)
            return data
        } catch {
            entries[url] = nil                         // allow a retry later
            throw error
        }
    }
}

Why it holds up:

  • No await between the check and the insert. Everything from if let entry to entries[url] = .inProgress(task) runs without suspending, so it’s atomic within the actor. Ten concurrent callers create one task; nine join it.
  • The shared download is a plain Task, deliberately: if one row’s caller is cancelled (the row scrolled away), the download continues for the others. To cancel when every caller is gone, keep a waiter count per entry and cancel the task when it drops to zero.
  • Failures aren’t cached, so a flaky network doesn’t leave a permanent hole.
  • Memory: the dictionary grows forever. Production code adds a cost limit and eviction (an LRU, or NSCache for the ready data, keeping only in-flight tasks in the actor), and decodes images off the main actor before display.
NeedTool
Two or three independent loadsasync let a = …; async let b = …; await (a, b)
A dynamic number of parallel taskswithTaskGroup / withThrowingTaskGroup, results returned, not shared
Shared mutable stateactor; Mutex (iOS 18) for tiny synchronous sections
UI state@MainActor (Swift 6.2: default isolation for app targets)
CPU work off the main actornonisolated + @concurrent (6.2), Sendable inputs/outputs
Start from SwiftUI.task, .task(id:), async closures like .refreshable
Escape hatches@unchecked Sendable, nonisolated(unsafe): only with a lock and a comment

Gotchas: reentrancy across await inside actors (lost updates, duplicate work), Task.detached drops isolation, unstructured tasks swallow errors and aren’t cancelled, cancellation is cooperative, async let that’s never awaited is cancelled implicitly at scope exit.

actor Loader {
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func data(for url: URL) async throws -> Data {
        if let task = inFlight[url] { return try await task.value }
        let task = Task { try await URLSession.shared.data(from: url).0 }
        inFlight[url] = task                  // before the first await
        defer { inFlight[url] = nil }
        return try await task.value
    }
}

Check this part

  1. Why must UI updates happen on the main actor?

  2. Why is Task.detached usually the wrong choice in a SwiftUI view?

  3. How does an image loader avoid downloading the same URL twice concurrently?

  4. A long CPU loop runs in a task that was cancelled. When does it stop?

UI state lives on the main actor; await suspends without blocking it. Task {} inherits the current actor while Task.detached inherits nothing. Run many loads with a task group that limits how many run at once, and return results instead of sharing state.

Key takeaways

  • await waits without blocking; async let and task groups run independent work in parallel.
  • An actor protects its state by handling calls one at a time; call it with await.
  • UI state belongs to the main actor; use .task and async closures instead of Task.detached.
  • Turn on the Swift 6 language mode: data races become compile errors instead of rare crashes.
  • Actors are reentrant: never keep state across await; store in-flight tasks to deduplicate work.
  • Value types and actor-isolated classes are Sendable; @unchecked Sendable needs a real lock (Mutex) and a reason.
  • Choose isolation deliberately: main actor for UI (the Swift 6.2 default option), actors for shared state, @concurrent nonisolated for CPU work.
  • Prefer structured concurrency; unstructured tasks need an owner that cancels them and observes their errors.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.