Swift 6 Concurrency Migration Notes from a Real Codebase
Practical lessons, architecture patterns, and debugging strategies from migrating a production iOS app to Swift 6 complete concurrency checking.
When Swift 6 was formally introduced, the promise was clear: eliminate data races at compile time. In theory, compile-time data race safety is the holy grail for iOS engineering. In practice, flipping -strict-concurrency=complete on an existing multi-module codebase felt like turning on the lights in an old warehouse. Hundreds of warnings and errors illuminated hidden race conditions that had quietly existed in our code for years.
Over the past two quarters, I led the migration of our primary iOS app modules to Swift 6. Here are the practical lessons, architectural shifts, and mental model adjustments that made the transition manageable.
The Core Problem: Accidental Shared State
In pre-Swift 6 codebases, data races typically hid behind three common architectural anti-patterns:
- Mutable Singletons: Shared managers (
SessionManager.shared,CacheCoordinator.shared) that read and mutated properties from background threads and completion handlers without synchronization. - Implicit Closure Captures: Escaping closures that captured mutable reference types (
class) across thread boundaries. - Unannotated UI Callbacks: Asynchronous network callbacks updating observable view model state without explicit
@MainActorguarantees.
Under Swift 5 with default settings, the compiler remained silent. Under Swift 6 complete concurrency, every cross-isolation boundary is strictly checked.
Strategy 1: Migrate by Module Boundaries, Not All-at-Once
Trying to fix all concurrency errors across an entire app target in a single pull request is a recipe for frustration and regressions. Instead, we adopted an incremental module-by-module migration:
- Leaf Data Modules First: Start with your lowest-level models, network DTOs, and utility libraries. Ensure all data structures conform to
Sendable. - Service Layer & Repositories: Convert mutable shared classes into
actortypes or immutable structs with explicit isolated access. - UI Layer & ViewModels: Mark presentation state and SwiftUI view models with
@MainActor.
In Xcode build settings, you can enable SWIFT_STRICT_CONCURRENCY = complete on individual frameworks while leaving the root app target on targeted or minimal until the dependencies are clean.
Strategy 2: Replacing Mutable Classes with Sendable Value Types
The fastest way to resolve compiler errors regarding non-Sendable types crossing isolation domains is to convert reference types into immutable structs.
// BEFORE (Swift 5): Reference type with mutable state causing race conditions
final class UserSession {
var token: String
var userProfile: Profile
var lastActive: Date
init(token: String, userProfile: Profile, lastActive: Date) {
self.token = token
self.userProfile = userProfile
self.lastActive = lastActive
}
}
// AFTER (Swift 6): Immutable Sendable value type
struct UserSession: Sendable, Equatable {
let token: String
let userProfile: Profile
let lastActive: Date
}
When mutation is strictly required across tasks, encapsulate the mutable state inside an actor rather than relying on manual NSLock or GCD queues:
actor SessionStore {
private var currentSession: UserSession?
func updateSession(_ session: UserSession) {
self.currentSession = session
}
func activeToken() -> String? {
return currentSession?.token
}
}
Strategy 3: Taming the @MainActor Propagation
One common point of confusion during migration is the infectious nature of @MainActor. When you annotate a ViewModel with @MainActor, all of its properties and methods inherit main-thread isolation:
@MainActor
final class ProductFeedViewModel: ObservableObject {
@Published private(set) var items: [ProductItem] = []
private let repository: ProductRepositoryProtocol
init(repository: ProductRepositoryProtocol) {
self.repository = repository
}
func loadProducts() async {
do {
// repository.fetchFeed() runs on a cooperative background thread
let results = try await repository.fetchFeed()
// Mutation of @Published items automatically occurs safely on MainActor
self.items = results
} catch {
// Handle error on main thread
}
}
}
If you have heavy data parsing or image transformation routines, keep them separated in non-isolated helper functions or actors so they do not block the main run loop.
Strategy 4: Handling Non-Sendable Third-Party Frameworks
You will inevitably encounter third-party libraries (or legacy Objective-C dependencies) that have not yet adopted Swift 6 annotations. When a type is provably thread-safe internally but lacks compiler markup, use @unchecked Sendable judiciously with clear documentation:
// Use with caution: document why this type is internally synchronized
final class LegacyAnalyticsTracker: @unchecked Sendable {
private let lock = NSLock()
func logEvent(_ name: String) {
lock.lock()
defer { lock.unlock() }
// Synchronized event dispatch logic
}
}
For closures and temporary migrations, sending parameters in Swift 6.0 allow passing non-Sendable values when the compiler can prove ownership transfer without concurrent sharing.
Key Takeaways
- Safety is proactive: Data race safety in Swift 6 prevents rare, impossible-to-reproduce production crashes before code ever merges.
- Design for immutability: Making core models
Sendablestructs eliminates 80 percent of concurrency friction. - Embrace structured concurrency: Prefer
async let,TaskGroup, and structured task trees over detached unstructuredTask { }blocks that escape cancellation.
Migrating to Swift 6 is an investment in architectural hygiene. Once your team adjusts to thinking in terms of actors and isolation domains, writing robust, thread-safe asynchronous code becomes natural.