NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Guide

Swift 6 Concurrency: async/await, Actors and Sendable Explained

Swift 6 turned concurrency warnings into hard errors, and the community felt it. This guide explains what changed, why it matters, and how to actually fix the compiler errors you are seeing. We cover async/await, actors, Sendable, MainActor, and the Swift 6.2 improvements that make it all more approachable.

Ahmed GaganAhmed Gagan
15 min read

The Big Picture

Swift 6 enforces complete data-race safety at compile time. Every concurrency warning from Swift 5.10 is now a hard error. The goal is to eliminate an entire class of bugs (data races) that are nearly impossible to debug at runtime. The tradeoff: your existing code will likely need changes. This guide walks you through each concept and the most common fixes. If you are also weighing Combine vs async/await, read that comparison alongside this one.

Why Swift 6 Changed the Rules

Data races are one of the nastiest bugs in programming. They happen when two threads access the same memory at the same time, and at least one of them is writing. The result is unpredictable: crashes, corrupted data, UI glitches that only happen in production on a Tuesday during a full moon. Traditional tools like Thread Sanitizer (TSan) can catch some of these at runtime, but they only find races that actually occur during testing. If your tests do not trigger a particular timing pattern, the bug ships.

Swift 6 takes a different approach. Instead of detecting data races at runtime, the compiler prevents them at build time. If your code could theoretically produce a data race, it does not compile. Period. This is a massive shift in philosophy, and it is why the migration can feel painful.

Apple introduced Swift Concurrency (async/await, actors, Sendable) at WWDC 2021 with Swift 5.5. For three years, the concurrency checks were optional warnings. Swift 6, released with Xcode 16 in late 2024, made them mandatory. The training wheels are off.

async/await: The Foundation

If you have used JavaScript, Kotlin, or C#, Swift's async/await will feel familiar. An async function can suspend execution while waiting for something (a network response, a database query, a file read) without blocking a thread. You call it with await.

func fetchUser(id: String) async throws -> User {
    let (data, _) = try await URLSession.shared.data(
        from: URL(string: "https://api.example.com/users/\(id)")!
    )
    return try JSONDecoder().decode(User.self, from: data)
}

The key insight: when a function hits an await, it yields control so other work can run on the same thread. When the awaited operation completes, the function resumes. No completion handlers, no callback pyramids, no retain cycles from captured closures.

In Swift 6, the rules around where async code runs have become stricter. Previously, a nonisolated async function would always hop to the global concurrent executor (a background thread pool). This caused unexpected behavior because calling an async function from the main actor would silently move execution off the main thread. Swift 6.2 fixes this, which we will cover later.

Actors: Thread-Safe Objects

An actor is like a class, but with built-in thread safety. Only one piece of code can access an actor's mutable state at a time. The Swift compiler enforces this by requiring all external access to an actor's properties and methods to be awaited.

actor ImageCache {
    private var cache: [URL: Data] = [:]

    func image(for url: URL) -> Data? {
        cache[url]
    }

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

Notice there are no locks, no dispatch queues, no @synchronized blocks. The actor keyword tells the compiler to serialize all access to this type's mutable state. If you try to access cache from outside the actor without await, the compiler rejects it.

Actors are implicitly Sendable because their isolation guarantees make them safe to share across concurrency domains. If you have a class that wraps a serial DispatchQueue for thread safety, converting it to an actor often simplifies the code significantly while giving you compile-time verification instead of runtime hopes.

Sendable: The Concurrency Contract

Sendable is the protocol at the heart of Swift 6's concurrency model. A type that conforms to Sendable is safe to pass across concurrency boundaries (from one actor to another, from the main thread to a background task, etc.).

Value types (structs, enums) with only Sendable properties are implicitly Sendable. Reference types (classes) are not Sendable by default because multiple parts of your code could hold a reference and mutate the object simultaneously.

TypeSendable by Default?How to Make Sendable
Struct (all Sendable properties)Yes (implicit)Automatic
Enum (all Sendable associated values)Yes (implicit)Automatic
ActorYes (implicit)Automatic
Final class (immutable, Sendable properties)NoAdd : Sendable conformance
Class with mutable stateNoConvert to actor, or use @unchecked Sendable
ClosureNoMark as @Sendable, capture only Sendable values

A quick word about @unchecked Sendable: it tells the compiler "trust me, this type is thread-safe even though you cannot verify it." Use it sparingly. It is essentially an escape hatch that disables the compiler's safety checks for that type. If you are wrong about the thread safety, you have a data race that no tool will catch. Reach for it only when you are wrapping a type you do not control (like a C library wrapper) that you know is thread-safe internally.

@MainActor: Keeping UI Work on the Main Thread

SwiftUI views must be updated on the main thread. Always. The @MainActor attribute tells the compiler that a type or function is isolated to the main thread. Any access from a different concurrency context requires await.

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false

    func loadUser() async {
        isLoading = true
        user = try? await APIService.shared.fetchUser()
        isLoading = false
    }
}

In Swift 6, if you use @StateObject or @ObservedObject in a view, the view and its view model should be marked @MainActor. This was automatically inferred in earlier Swift versions, but Swift 6's stricter checks mean you often need to be explicit.

The pattern is straightforward: mark your view models @MainActor, do your async work inside async methods, and let the compiler enforce that all UI state mutations happen on the main thread. No more forgetting a DispatchQueue.main.async call and getting a runtime crash.

Common Swift 6 Compiler Errors (and How to Fix Them)

Let me walk through the errors you are most likely to encounter during migration. I have seen all of these in real projects.

1. “Passing argument of non-sendable type outside of actor-isolated context”

This appears when you try to pass a non-Sendable value across a concurrency boundary. For example, sending a class instance from the main actor to a background task.

Fix: Make the type Sendable (use a struct instead of a class, or add : Sendable conformance if the class is final with immutable properties). Alternatively, copy the data you need into a Sendable form before crossing the boundary.

2. “Main actor-isolated property cannot be referenced from a Sendable closure”

You will hit this in SwiftUI when a .task { } modifier or a Task { } closure captures a @State or @Published property.

Fix: Wrap the closure body in await MainActor.run { } or annotate the Task with Task { @MainActor in ... }. In most SwiftUI contexts, using .task on a view already runs on the main actor, but the compiler sometimes needs explicit help.

3. “Actor-isolated property cannot be mutated from a non-isolated context”

This means you are trying to set an actor's property from outside the actor without going through an async call.

Fix: Add await before the property access, or move the mutation into an actor method and call that method with await.

4. “Sending value of non-Sendable type risks causing data races”

This is a newer, more specific error in Swift 6. It appears when the compiler detects that a value could be accessed from multiple isolation domains.

Fix: The approach depends on the type. For model objects, convert to a struct. For service classes, convert to an actor. For closures, ensure they only capture Sendable values. If you truly need a non-Sendable reference type shared across actors, consider wrapping it in an actor.

5. “Expression is 'async' but is not marked with 'await'”

Actor methods and properties are implicitly async when accessed from outside the actor. This is not new to Swift 6, but stricter enforcement catches more cases.

Fix: Add await. If you are in a synchronous context, you need to either make the calling function async or wrap the call in a Task.

Migration Tip

Do not try to fix everything at once. In Xcode, go to Build Settings, find “Strict Concurrency Checking” under “Swift Compiler - Language,” and set it to Minimal first. This shows warnings instead of errors. Work through them module by module. Once clean, switch to Complete to enforce them as errors. This gradual approach saved my sanity during migration.

Swift 6.2: Approachable Concurrency

The Swift team acknowledged that Swift 6's strict concurrency was harder to adopt than it should have been. Swift 6.2, shipping with Xcode 26 in 2025, introduced "Approachable Concurrency" with several changes designed to reduce friction.

Default MainActor Isolation

New Xcode 26 projects can opt into making @MainActor the default isolation for all code. This means your code runs on the main thread unless you explicitly move it elsewhere. For UI-heavy apps (which most indie apps are), this is a huge quality-of-life improvement. You no longer need to sprinkle @MainActor on every view model, view, and callback. It just works.

You can enable this in your build settings or package manifest by passing MainActor.self as the default isolation. Existing projects can adopt it too, though you may need to audit background work to ensure it is still explicitly moved off the main actor.

Nonisolated Async Functions Inherit Caller Isolation

This is one of the most impactful changes. In Swift 6.0, a nonisolated async function always ran on the global concurrent executor, even if you called it from the main actor. This caused endless confusion because calling an async method on a class would silently hop to a background thread.

Swift 6.2 changes the default (via SE-0461): nonisolated async functions now inherit the caller's isolation. If you call them from the main actor, they run on the main actor. If you call them from a background actor, they run on that actor. This eliminates a massive category of unexpected thread-hopping bugs.

The @concurrent Attribute

With the new default of inheriting caller isolation, you need a way to explicitly opt into background execution for CPU-heavy work. That is what @concurrent does. Annotate a function with @concurrent and it will run on the global executor (a background thread), regardless of the caller's isolation.

Use @concurrent for JSON decoding, image processing, file parsing, or anything else that would block the main thread. It makes the intent explicit: "this work should not run on the caller's actor."

Isolated Conformances

A long-standing pain point: a @MainActor class could not conform to a protocol like Equatable because the compiler could not guarantee the conformance methods would only be called from the main actor. Swift 6.2 introduces isolated conformances, allowing actor-isolated types to conform to protocols within their actor context. This eliminates a lot of boilerplate workarounds.

Practical Migration Strategy

Here is the approach I recommend for migrating an existing Swift 5 project to Swift 6 concurrency:

  1. Update to Swift 6.2 / Xcode 26. Start with the latest tooling. The Approachable Concurrency improvements in 6.2 eliminate many of the errors you would otherwise have to fix manually.
  2. Enable Strict Concurrency at the "Minimal" level. This gives you warnings instead of errors. You can still build and run your app while fixing issues incrementally.
  3. Fix your models first. Make your data models (structs, enums) conform to Sendable. Most value types are already implicitly Sendable if their properties are.
  4. Convert service classes to actors. Any class that manages shared mutable state (caches, network managers, database wrappers) is a prime candidate for conversion to an actor.
  5. Mark view models @MainActor. Your ObservableObject view models should be explicitly @MainActor. This matches how SwiftUI actually uses them.
  6. Audit Task and .task usage. Make sure closures capture only Sendable values. Use @MainActor in when updating UI state from tasks.
  7. Enable "Complete" checking. Once all warnings are resolved, switch to full enforcement. From here on, the compiler has your back.

Community Sentiment: Is It Worth It?

Let me be honest: the Swift community was split on Swift 6 concurrency when it first shipped. Many developers found the migration painful, especially those with large UIKit codebases. The volume of new compiler errors felt overwhelming, and some of the error messages were genuinely confusing. The sentiment was that "Swift 6 is right, but it is too aggressive for most teams to adopt quickly."

That sentiment has improved significantly with Swift 6.2. The Approachable Concurrency changes address the most common frustrations, and the fact that new projects default to MainActor isolation means new developers do not have to understand the full actor model just to get started. The Swift team's willingness to iterate based on community feedback has been encouraging.

My take: the safety benefits are real. I have shipped apps with data races that took days to reproduce and debug. Swift 6 makes that category of bug impossible (in Swift code, at least). The migration cost is front-loaded, but once you are through it, you have a codebase with compile-time guarantees that no amount of testing could provide. If you are starting a new project today, there is no reason not to go full Swift 6.

Swift 6 Concurrency in SwiftUI Apps

For a typical SwiftUI app using MVVM architecture, here is what the concurrency model looks like in practice:

  • Views: Run on the main actor (automatic in SwiftUI).
  • View Models: Annotate with @MainActor. Use async methods for data loading.
  • Repositories/Services: Convert to actors. Methods are async and internally serialize access to shared state.
  • Models: Use structs (implicitly Sendable). Pass them freely between actors and tasks.
  • Network layer: Use async/await with URLSession. See our async/await networking tutorial for the full pattern.

This architecture naturally separates concerns and concurrency domains. Views and view models live on the main actor. Services live on their own actors. Data flows between them as Sendable structs. The compiler verifies every boundary crossing.

Start Building with Confidence

Swift 6 concurrency is a major step forward for the language. It is not always easy to adopt, but the tools are improving rapidly, and the safety guarantees are worth the investment.

If you are starting a new project, The Swift Kit is already built with Swift 6 best practices: @MainActor view models, async service layers, Sendable models, and a clean MVVM architecture that plays well with strict concurrency. You do not have to figure out the right patterns from scratch.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit