Logo AlokChoudhary.com
Swift 6 Concurrency Migration Notes from a Real Codebase

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.

Alok Choudhary
Austin, TX, USA
3 min read

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:

  1. Mutable Singletons: Shared managers (SessionManager.shared, CacheCoordinator.shared) that read and mutated properties from background threads and completion handlers without synchronization.
  2. Implicit Closure Captures: Escaping closures that captured mutable reference types (class) across thread boundaries.
  3. Unannotated UI Callbacks: Asynchronous network callbacks updating observable view model state without explicit @MainActor guarantees.

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:

  1. Leaf Data Modules First: Start with your lowest-level models, network DTOs, and utility libraries. Ensure all data structures conform to Sendable.
  2. Service Layer & Repositories: Convert mutable shared classes into actor types or immutable structs with explicit isolated access.
  3. 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

  1. Safety is proactive: Data race safety in Swift 6 prevents rare, impossible-to-reproduce production crashes before code ever merges.
  2. Design for immutability: Making core models Sendable structs eliminates 80 percent of concurrency friction.
  3. Embrace structured concurrency: Prefer async let, TaskGroup, and structured task trees over detached unstructured Task { } 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.

Link copied to clipboard!

Made with ❤️ in Austin.

Copyright © 2026