When you enable Swift 6 mode in Xcode, the compiler suddenly feels like an adversary. Code that compiled cleanly and ran in production without apparent bugs for five years is suddenly flagged with red diagnostics across dozens of files:
Capture of 'self' with non-sendable type 'ProfileService' in a `@Sendable` closure
Passing argument of non-sendable type '(any Error)? -> Void' into actor-isolated context
Main actor-isolated property 'items' can not be mutated from a non-isolated context
The immediate temptation for many teams under deadline pressure is to reach for escape hatches: sprinkle @unchecked Sendable across every model class, add @preconcurrency import to third-party frameworks, and slap @MainActor onto random utility functions until Xcode stops shouting.
That approach is dangerous. It trades compile-time verification for runtime data races, defeating the entire purpose of Swift’s concurrency model.
Swift 6 is not being pedantic for the sake of academic purity. It is eliminating the single most elusive class of mobile crashes: concurrent access to shared mutable memory.
Once you understand three fundamental rules of the Swift 6 concurrency model, the compiler errors stop feeling like arbitrary roadblocks and start looking like clear architectural design cues.
The core mental model: three questions
Whenever the Swift 6 compiler flags a concurrency diagnostic, it is asking three specific questions:
- Where does this state live? (Is it isolated to
@MainActor, isolated to a customactor, or non-isolated?) - Can this value safely cross a thread boundary? (Is it
Sendable?) - Who is executing this closure? (Does the closure run synchronously on the current thread, or asynchronously across a
Task?)
If you can answer those three questions for every type and function in your feature, the fix becomes obvious.
Rule 1: Prefer value types over classes for domain state
The single highest-leverage change you can make when preparing an iOS codebase for Swift 6 is converting mutable model classes into immutable structs:
// ❌ Problematic in Swift 6: Reference type shared across tasks
final class OrderModel {
var id: String
var items: [String]
var isProcessed: Bool = false
init(id: String, items: [String]) {
self.id = id
self.items = items
}
}
Because OrderModel is a mutable reference type, passing an instance from a background networking task to a SwiftUI view model creates a data race: both threads hold a pointer to the exact same heap memory. The compiler will reject this unless OrderModel conforms to Sendable — which it cannot safely do while its properties are mutable var.
The fix is almost always to model state as a value type:
// ✅ Safe in Swift 6: Value semantics mean automatic Sendable conformance
struct Order: Sendable, Identifiable, Equatable {
let id: String
let items: [String]
let isProcessed: Bool
}
When a struct contains only Sendable properties (like String, Int, Array, or other value types), Swift synthesizes Sendable automatically. When you pass an Order into a background Task, Swift passes an independent copy. There is no shared memory, no lock contention, and zero compiler resistance.
Rule 2: Put shared mutable state behind an Actor
Value types solve state transmission, but you still need places where mutable state lives over time — like an in-memory token cache, an image cache, or an active download manager.
Instead of synchronizing with manual GCD queues (DispatchQueue(label: ...)) or os_unfair_lock, encapsulate that state inside an actor:
// ✅ An actor guarantees mutually exclusive access to its internal state
actor TokenStorage {
private var accessToken: String?
private var expirationDate: Date?
func updateToken(_ token: String, expiresAt: Date) {
self.accessToken = token
self.expirationDate = expiresAt
}
func validToken() -> String? {
guard let token = accessToken, let expirationDate, expirationDate > Date() else {
return nil
}
return token
}
func clear() {
self.accessToken = nil
self.expirationDate = nil
}
}
Because TokenStorage is an actor, callers cannot mutate accessToken directly. Calling tokenStorage.updateToken(...) requires await, ensuring that even if five concurrent network requests try to refresh a token simultaneously, access is serialized safely without data races or manual lock management.
Rule 3: Bridging legacy completion handlers with continuations
Most existing iOS codebases interact with legacy SDKs or Objective-C frameworks that rely on closure completion handlers:
// Legacy API
func fetchUserProfile(userId: String, completion: @escaping (Result<UserProfile, Error>) -> Void)
If you call this from an async context in Swift 6, you will often trigger errors regarding non-Sendable closure captures or crossing actor boundaries.
The clean bridge is wrapping completion handlers using Swift’s checked continuations:
extension ProfileService {
func userProfile(for userId: String) async throws -> UserProfile {
try await withCheckedThrowingContinuation { continuation in
self.fetchUserProfile(userId: userId) { result in
switch result {
case .success(let profile):
continuation.resume(returning: profile)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
Continuations turn asynchronous callback hell into standard, linear async/await syntax. withCheckedThrowingContinuation also verifies at runtime that the continuation is resumed exactly once — if a third-party SDK calls the completion twice or forgets to call it at all, Xcode will trap immediately with an explicit error.
Aligning SwiftUI ViewModels with @MainActor
In modern SwiftUI (especially iOS 17+ with the @Observable macro), view models belong on the main actor:
import Observation
import SwiftUI
@Observable
@MainActor
final class OrderDetailViewModel {
var order: Order?
var isLoading: Bool = false
var errorMessage: String?
private let orderService: OrderFetching // Sendable protocol
init(orderService: OrderFetching) {
self.orderService = orderService
}
func load(orderId: String) async {
isLoading = true
errorMessage = nil
do {
// Network fetch executes off the main thread
self.order = try await orderService.fetchOrder(id: orderId)
} catch {
self.errorMessage = error.localizedDescription
}
isLoading = false
}
}
Notice what happens here:
- The class is annotated with
@MainActor. All property access (isLoading,order,errorMessage) is guaranteed to happen on the main thread, meaning SwiftUI can read and observe them without UI stutter or background publishing warnings. - The
orderService.fetchOrdermethod is non-isolated and runs on cooperative background threads. When it returns, execution hops back onto@MainActorautomatically before updatingself.order. - You never need to write
DispatchQueue.main.asyncagain.
When (and when NOT) to use @unchecked Sendable
There is one legitimate use case for @unchecked Sendable: when a class manages its own internal synchronization using thread-safe primitives that the Swift compiler cannot verify automatically (such as an internal C library or atomic locks).
// Legitimate use: internal state protected by a fair lock
final class ConcurrentCounter: @unchecked Sendable {
private var lock = os_unfair_lock_s()
private var count = 0
func increment() {
os_unfair_lock_lock(&lock)
count += 1
os_unfair_lock_unlock(&lock)
}
func currentCount() -> Int {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return count
}
}
If you are using @unchecked Sendable on a regular model class just because you have var properties and didn’t feel like refactoring them into a struct or actor, you have introduced an untracked data race into your application.
The migration checklist for existing apps
If you have an existing iOS app targeting Swift 5 and want to migrate cleanly to Swift 6:
- Enable complete concurrency warnings first: Set
SWIFT_STRICT_CONCURRENCY = completein Build Settings under Swift 5 mode. Fix the warnings gradually while your builds still pass. - Audit your models: Convert classes that represent data transfers or API responses into immutable
structtypes. - Decorate UI view models with
@MainActor: Make actor boundaries explicit. - Wrap completion handlers in
async/await: Replace completion blocks withwithCheckedThrowingContinuation. - Only then flip the language version to Swift 6: When the warnings drop to zero, enable Swift 6 mode.
Swift 6 strict concurrency feels challenging at first because it forces decisions you were previously able to postpone. But once your codebase complies with its rules, random race conditions, sporadic UI update glitches, and intermittent multithreaded crash reports disappear completely.