If you have recently enabled Swift 6 language mode or turned on "Strict Concurrency Checking" in Xcode, you have likely encountered the build-breaking wall of red errors. The most persistent among them is usually:
"Passing argument of non-sendable type 'X' outside of its actor-isolated context may introduce data races."
This error often appears when you attempt to pass a standard class (like a View Model or a data object) into a Task, or when moving data between background threads and the @MainActor.
In Swift 6, data safety is no longer a suggestion—it is a compiler requirement. This guide breaks down exactly why this error occurs and provides three architectural patterns to resolve it without compromising your app's performance.
The Root Cause: Why "Non-Sendable" Matters
To fix the error, you must understand the "Isolation Boundary."
In the older Grand Central Dispatch (GCD) world, it was the developer's responsibility to ensure that a mutable class instance wasn't modified by two threads simultaneously. If you failed, the app crashed randomly in production.
Swift 6 shifts this responsibility to the compiler.
The Definition of Sendable
The Sendable protocol is a marker. It tells the compiler: "It is safe to copy this value across thread boundaries."
- Value Types (Structs/Enums): Are implicitly
Sendableif all their properties areSendable. Copies are independent; changing one copy does not affect the other. - Reference Types (Classes): Are not
Sendableby default. If you pass a class instance to a background task, both the main thread and the background thread hold a reference to the same memory address. This creates a potential data race.
When you see the "Non-Sendable" error, the compiler is refusing to compile code that might allow a race condition.
The Scenario: A Typical Build Failure
Consider a common scenario where a mutable class stores user state. This code works in Swift 5 but fails under Swift 6 strict concurrency.
// A typical mutable class (Reference Type)
class UserProfile {
var username: String
var age: Int
init(username: String, age: Int) {
self.username = username
self.age = age
}
}
@MainActor
class ProfileViewModel {
var user: UserProfile
init(user: UserProfile) {
self.user = user
}
func updateProfile() {
// ERROR: Capture of 'self.user' with non-sendable type 'UserProfile'
// in a `@Sendable` closure
Task.detached {
print("Saving user: \(self.user.username)")
// Network request simulation...
}
}
}
The Task.detached creates a new execution context. Passing self.user (a reference type) into that closure crosses an isolation boundary. Since UserProfile is not thread-safe, the compiler blocks it.
Solution 1: Convert to Value Types (The Preferred Fix)
The most robust architectural fix is to favor struct over class for data models. Structs have value semantics. When a struct is passed into a closure, it is copied. The background task gets its own copy, eliminating race conditions.
The Implementation
Change the data model definition from class to struct.
// implicitly Sendable because String and Int are Sendable
struct UserProfile {
var username: String
var age: Int
}
@MainActor
class ProfileViewModel {
var user: UserProfile
init(user: UserProfile) {
self.user = user
}
func updateProfile() {
// Capture a snapshot of the value
let userSnapshot = self.user
Task.detached {
// This is now safe. 'userSnapshot' is a distinct copy.
print("Saving user: \(userSnapshot.username)")
}
}
}
Why this works: The compiler sees that UserProfile is a struct containing only Sendable primitives. It automatically conforms to Sendable. Passing it across boundaries is thread-safe because no memory is shared.
Solution 2: Actor Isolation (The "View Model" Fix)
Sometimes you cannot use a struct. You might be dealing with a reference that needs to be shared, or an existing View Model that is too large to refactor entirely.
In this case, you must ensure the class lives entirely within a specific isolation context—usually the @MainActor for UI-related logic.
The Implementation
If the class is annotated with @MainActor, all its properties and methods are isolated to the main thread. To access them from a background task, you must await them, ensuring thread safety.
However, typically you want to pass data out of the actor. To do this, do not pass the whole class. Pass the specific data needed.
// This class remains non-Sendable
class UserManager {
var username: String = "Guest"
}
@MainActor
class DashboardViewModel {
let manager = UserManager()
func performBackgroundWork() {
// 1. Extract the specific primitive data needed (Strings are Sendable)
let nameToProcess = manager.username
Task {
// 2. Perform work off the main actor using the extracted data
let processedName = await processName(nameToProcess)
// 3. Update state back on the MainActor
self.manager.username = processedName
}
}
// This function runs on a generic background thread
nonisolated func processName(_ name: String) async -> String {
// Simulate heavy work
try? await Task.sleep(nanoseconds: 1_000_000_000)
return name.uppercased()
}
}
Why this works: We are not passing the UserManager class into the background. We are extracting the String (which is Sendable) and passing that. The UserManager reference never leaves the @MainActor.
Solution 3: Checked Sendable with Internal Locking
If you absolutely must pass a reference type across threads (e.g., a shared cache or a logging service), you must make the class conform to Sendable.
Because classes are mutable, the compiler cannot verify safety automatically. You must use final, conform to Sendable, and use internal locking to prove safety.
The Implementation
We use OSAllocatedUnfairLock (available in newer iOS versions) or NSLock to synchronize access.
import os.lock
// 1. Class must be final to conform to Sendable
final class ThreadSafeLogger: Sendable {
// 2. State must be private
private let state = OSAllocatedUnfairLock(initialState: [String]())
func addLog(_ message: String) {
// 3. All mutations happen inside the lock
state.withLock { logs in
logs.append(message)
}
}
func getLogs() -> [String] {
state.withLock { logs in
// Return a copy of the logs
return logs
}
}
}
@MainActor
class AppController {
let logger = ThreadSafeLogger()
func run() {
// Since ThreadSafeLogger is Sendable, we can capture it directly
Task.detached {
self.logger.addLog("Background task started")
}
}
}
Why this works:
final: Prevents subclassing, ensuring behavior doesn't change.Sendable: We explicitly tell the compiler we handled the threading.- Locking: We ensure that even if two threads call
addLoginstantly, the underlying array is accessed serially.
Edge Case: @unchecked Sendable
You will encounter the @unchecked Sendable attribute in documentation.
class LegacyDatabase: @unchecked Sendable { ... }
Do not use this unless absolutely necessary.
Using @unchecked tells the compiler: "Turn off safety checks for this class; I promise I wrote thread-safe C-code inside." If you use this on a standard Swift class with var properties and no locks, you are re-introducing the random crashes Swift 6 tries to prevent. Only use this if you are wrapping a C-pointer or an inherently thread-safe system framework that hasn't been updated yet.
Conclusion
The "Non-Sendable" error is the most common friction point in the Swift 6 migration, but it pushes code toward better architecture.
- Default to Structs: If it's just data, use a value type.
- Isolate Scope: If it's a UI object, keep it on
@MainActorand only pass primitives to background tasks. - Synchronize Internally: If a class must be shared, lock its state and mark it
final class ... : Sendable.
By respecting the isolation boundary rather than fighting the compiler, you ensure your iOS application is free of data races and ready for the future of Swift.