DEV Community

Cover image for Swift 6 Strict Concurrency: Production Migration Playbook
Muhittin Çamdalı
Muhittin Çamdalı

Posted on Originally published at muhittincamdali.com AI-assisted

Swift 6 Strict Concurrency: Production Migration Playbook

Swift 6 Strict Concurrency: Production Migration Playbook

Swift 6.0's strict concurrency looks, at first glance, like an opt-in compile flag. But in reality? It's a serious refactor mandate touching every one of your classes, singletons, delegate patterns, and dependency-injection architecture.

This article walks through the 12 common pitfalls you are likely to hit when moving a large production iOS codebase to strict concurrency mode, their fixes, and the correct actor migration order. Spoiler: @MainActor is not the cure for everything.

Pro Tip: Before starting the migration, don't begin with SWIFT_STRICT_CONCURRENCY = complete — start with targeted. Targeted mode only checks Sendable; complete mode also enforces actor isolation. Starting targeted cuts your first-pass error count in half.


1. The Three Levels of Strict Concurrency Mode

Swift 6 strict concurrency is controlled by the SWIFT_STRICT_CONCURRENCY build setting, and it has three levels:

# Minimal — Swift 5.x default behavior
SWIFT_STRICT_CONCURRENCY = minimal

# Targeted — only Sendable explicit marked types check
SWIFT_STRICT_CONCURRENCY = targeted

# Complete — full Swift 6 strict concurrency
SWIFT_STRICT_CONCURRENCY = complete
Enter fullscreen mode Exit fullscreen mode

Practical migration strategy:

  1. Weeks 1-2: Move the whole project from minimal to targeted. Fix Sendable warnings.
  2. Weeks 3-4: Turn on complete mode module by module. The UI layer goes last.
  3. Week 5: Full project on complete. Swift 6 language mode active.

Migrating small modules first (Networking, Persistence) reduces production risk — changes that leak into the UI layer are usually the most expensive.


2. Sendable: The End of Invisible Leaks

A large share of the migration work comes down to Sendable conformance. A type being Sendable means it's safe to share across multiple threads.

// ❌ Strict mode error: User isn't Sendable
class User {
    var name: String
    var preferences: [String: Any]  // [String: Any] isn't Sendable!
}

// ✅ Solution: struct + typed properties
struct User: Sendable {
    let id: UUID
    let name: String
    let preferences: UserPreferences  // Sendable Codable struct
}
Enter fullscreen mode Exit fullscreen mode

Common Sendable pitfalls:

  • [String: Any] — Any isn't Sendable; use a typed dictionary
  • URL — Sendable ✓, URLRequest Sendable ✓
  • Date — Sendable ✓ (Swift 5.5+)
  • UIImage — not Sendable (reference type, mutable)
  • NSData — not Sendable; Data is Sendable ✓
  • Closure types — require the @Sendable annotation

Pro Tip: Don't try to make legacy Objective-C types (NSObject subclasses) Sendable — they're reference types and mutable. Wrap them in a pure-Swift struct instead.


3. Actor Migration Order: The Right Way

Actors are the gold standard for concurrent state in Swift 6. But migrating them in the wrong order means walking into a maze of rework. Recommended order:

  1. Stateless services (URLSession wrapper, JSON decoder) — not an actor, use struct or enum with static methods
  2. Stateful services (cache, session manager) — make them an actor
  3. DI container / service locator — global state, actor or @MainActor class
  4. ViewModels — @MainActor (they have a UI dependency)
  5. Views — SwiftUI is already MainActor-isolated
// Stateful cache → actor (Correct)
actor ImageCache {
    private var cache: [URL: UIImage] = [:]

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

    func setImage(_ image: UIImage, for url: URL) {
        cache[url] = image
    }
}

// Usage: await required
let image = await imageCache.image(for: url)
Enter fullscreen mode Exit fullscreen mode

Pitfall: don't try to preserve your old synchronous APIs when converting to actor. Every public method becomes async. If call sites are too scattered, use a transition wrapper:

@MainActor
final class ImageCacheBridge {
    private let cache: ImageCache

    nonisolated func imageSync(for url: URL) -> UIImage? {
        // Sync fallback for legacy paths (deprecated)
        return MainActor.assumeIsolated {
            self.legacyCacheStorage[url]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

[[demo:actor-race-sim]]

4. @MainActor Pitfalls

@MainActor is the most powerful tool for UIKit/SwiftUI interop. But misuse hurts performance badly.

Pitfall 1: Marking the entire class @MainActor

// ❌ Wrong — JSON parsing blocks the main thread
@MainActor
final class APIClient {
    func fetchUsers() async throws -> [User] {
        let data = try await URLSession.shared.data(from: usersURL).0
        return try JSONDecoder().decode([User].self, from: data)  // 200ms main thread block!
    }
}

// ✅ Correct — only UI-bound method @MainActor
final class APIClient {
    nonisolated func fetchUsers() async throws -> [User] {
        let data = try await URLSession.shared.data(from: usersURL).0
        return try JSONDecoder().decode([User].self, from: data)
    }

    @MainActor
    func updateUI(with users: [User]) {
        // UI update logic
    }
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Incorrectly capturing values in @MainActor closures

Task { @MainActor in
    // This whole block runs on the main thread — don't do heavy work here!
    let processed = self.heavyProcessing()  // main blocking!
    self.updateView(with: processed)
}

// ✅ Correct — heavy work in background, only UI update on main
Task {
    let processed = await self.heavyProcessing()  // arbitrary executor
    await MainActor.run {
        self.updateView(with: processed)
    }
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: MainActor.assumeIsolated is for synchronous compatibility, but misuse causes a runtime crash. Only use it where you can guarantee the code is already running on the main thread.


5. Async/Await and Existing Callback Patterns

A large, older codebase can easily have hundreds of closure-based APIs. Migrating all of them to async/await at once isn't feasible.

Bridge pattern:

// Old callback API
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
    // legacy code
}

// Async wrapper
func fetchData() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        fetchData { result in
            continuation.resume(with: result)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Watch out — withCheckedThrowingContinuation pitfalls:

  • The continuation is one-shot — resuming it twice by mistake crashes
  • If you store the closure and never resume it, the async caller hangs forever
  • Cancellation handling must be explicit — use withTaskCancellationHandler

In production, use @CheckedContinuation to catch continuation leaks (Swift 6 default, has a debug check).


6. Does the Singleton Pattern Survive Strict Concurrency?

You're told singletons are an anti-pattern, but in a codebase with 200K LOC and 8 years of history, they exist and aren't going anywhere. How do they survive strict concurrency?

// ❌ Strict mode error
class AnalyticsTracker {
    static let shared = AnalyticsTracker()  // mutable global state warning
    var sessionID: String = UUID().uuidString
}

// ✅ Solution 1: actor
actor AnalyticsTracker {
    static let shared = AnalyticsTracker()
    var sessionID: String = UUID().uuidString

    func setSession(_ id: String) {
        sessionID = id
    }
}

// ✅ Solution 2: immutable
final class AnalyticsTracker: Sendable {
    static let shared = AnalyticsTracker()
    let sessionID = UUID().uuidString  // immutable, Sendable safe
    private init() {}
}
Enter fullscreen mode Exit fullscreen mode

Decision matrix:

  • Internal state gets mutated → actor
  • Pure read-only → Sendable final class
  • UI-bound singleton → @MainActor final class

7. Delegate Pattern → AsyncSequence Migration

Most Apple frameworks are delegate-based. Under strict concurrency, delegate methods need Sendable conformance.

// Old pattern
class LocationTracker: NSObject, CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // delegate callback
    }
}

// Modern pattern — AsyncSequence
class LocationTracker {
    var locations: AsyncStream<CLLocation> {
        AsyncStream { continuation in
            let bridge = LocationBridge { location in
                continuation.yield(location)
            }
            // CLLocationManager setup
        }
    }
}

// Usage — clean
for await location in tracker.locations {
    await viewModel.updateLocation(location)
}
Enter fullscreen mode Exit fullscreen mode

This pattern works the same way for MapKit, BLE, Camera, and Audio — wrap the old delegates in an AsyncStream.


8. Test Mocking + Sendable

Test mocks need to be Sendable, otherwise tests fail under the concurrent runner.

// Test double — Sendable conform
final class MockUserService: UserServicing, Sendable {
    let stubUsers: [User]

    init(users: [User] = []) {
        self.stubUsers = users
    }

    func fetchUsers() async throws -> [User] {
        stubUsers
    }
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: XCTest's default test parallelization does a Sendable check. If a test class holds state, mark it @MainActor or move that state into an actor.


9. Production Profiling: The Performance Trade-off

Strict concurrency isn't free. Actor hops add a small cost to every async call. These are the metrics to compare in your own app before and after the migration:

Metric Why it matters How to measure
Launch time Actor hops added to the main thread can delay the first screen Instruments App Launch template + MetricKit
Memory baseline Extra tasks and actor objects can raise the memory floor Xcode Memory Report + MetricKit
CPU under load Frequent actor hops can raise CPU share in busy flows Instruments Time Profiler
Build time Complete mode makes type checking heavier and builds slower Xcode Build Timeline

Build time is usually the most noticeable increase. Fix: incremental build caching and modularization.

Runtime overhead stays negligible for most mobile apps; still, verify hot paths with Time Profiler.

On the crash side, the expected gain is that data races closed at compile time no longer surface as runtime crashes; measure the effect by comparing crash analytics before and after the migration.


10. Migration Checklist

A recommended sequential checklist for migrating a production app:

  • [ ] Phase 0: Project on SWIFT_STRICT_CONCURRENCY = targeted, clean build
  • [ ] Phase 1: Migrate the networking layer, Sendable conformance
  • [ ] Phase 2: Persistence layer (Core Data, SwiftData) actor migration
  • [ ] Phase 3: Service container (DI) as actor or @MainActor
  • [ ] Phase 4: Mark ViewModels @MainActor
  • [ ] Phase 5: Mark UIKit ViewControllers @MainActor (SwiftUI views already are)
  • [ ] Phase 6: Turn on SWIFT_STRICT_CONCURRENCY = complete
  • [ ] Phase 7: Swift 6 language mode (SWIFT_VERSION = 6)
  • [ ] Phase 8: Enforce strict mode in the CI/CD pipeline
  • [ ] Phase 9: Two weeks of crash-analytics observation
  • [ ] Phase 10: Publish the performance baseline

Pro Tip: Don't do the migration on a feature branch — go gradual on main. On a large codebase the migration can take weeks or even months, so conflicts on a long-lived branch are unavoidable. Merging module-by-module into main is far more sustainable.


Conclusion

Strict concurrency is the delivery on Swift's 8-year-old safety promise. The ability to catch data races at compile time is valuable because it closes this class of runtime crashes at the source. But the migration isn't cheap — on a large codebase, budget for effort that can stretch over months, noticeably longer build times, and a small runtime overhead.

The pragmatic call: for new projects, Swift 6 + complete strict concurrency by default. For existing projects, a targeted → complete transition on a phased, module-by-module plan. @MainActor isn't the cure for everything; used correctly, it means keeping heavy work off the main thread with nonisolated.

Related resources:


Originally published at muhittincamdali.com.

Top comments (0)