# Swift 6 Strict Concurrency: 60K LOC 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.
In this article I walk through the 12 common pitfalls, real-world fixes, and the correct actor migration order I hit while moving a 60K-line production iOS app to strict concurrency mode. Spoiler: @MainActor is not the cure for everything.
Pro Tip: Before starting the migration, don't begin withSWIFT_STRICT_CONCURRENCY = complete— start withtargeted. Targeted mode only checks Sendable; complete mode also enforces actor isolation. Starting targeted cuts your first-pass error count in half.
Table of Contents
- The Three Levels of Strict Concurrency Mode
- Sendable: The End of Invisible Leaks
- Actor Migration Order: The Right Way
@MainActorPitfalls (5 of the 12)- Async/Await and Existing Callback Patterns
- Does the Singleton Pattern Survive Strict Concurrency?
- Delegate Pattern → AsyncSequence Migration
- Test Mocking + Sendable
- Production Profiling: The Performance Trade-off
- Migration Checklist
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:
1# Minimal — Swift 5.x default behavior2SWIFT_STRICT_CONCURRENCY = minimal3 4# Targeted — only Sendable explicit marked types check5SWIFT_STRICT_CONCURRENCY = targeted6 7# Complete — full Swift 6 strict concurrency8SWIFT_STRICT_CONCURRENCY = completePractical migration strategy:
- Weeks 1-2: Move the whole project from
minimaltotargeted. Fix Sendable warnings. - Weeks 3-4: Turn on
completemode module by module. The UI layer goes last. - 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
About 60% of the migration comes down to Sendable conformance. A type being Sendable means it's safe to share across multiple threads.
1// ❌ Strict mode error: User isn't Sendable2class User {3 var name: String4 var preferences: [String: Any] // [String: Any] isn't Sendable!5}6 7// ✅ Solution: struct + typed properties8struct User: Sendable {9 let id: UUID10 let name: String11 let preferences: UserPreferences // Sendable Codable struct12}Common Sendable pitfalls:
[String: Any]—Anyisn't Sendable; use a typed dictionaryURL— Sendable ✓,URLRequestSendable ✓Date— Sendable ✓ (Swift 5.5+)UIImage— not Sendable (reference type, mutable)NSData— not Sendable;Datais Sendable ✓- Closure types — require the
@Sendableannotation
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:
- Stateless services (URLSession wrapper, JSON decoder) — not an actor, use
structorenumwith static methods - Stateful services (cache, session manager) — make them an
actor - DI container / service locator — global state,
actoror@MainActorclass - ViewModels —
@MainActor(they have a UI dependency) - Views — SwiftUI is already MainActor-isolated
1// Stateful cache → actor (Correct)2actor ImageCache {3 private var cache: [URL: UIImage] = [:]4 5 func image(for url: URL) -> UIImage? {6 cache[url]7 }8 9 func setImage(_ image: UIImage, for url: URL) {10 cache[url] = image11 }12}13 14// Usage: await required15let image = await imageCache.image(for: url)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:
1@MainActor2final class ImageCacheBridge {3 private let cache: ImageCache4 5 nonisolated func imageSync(for url: URL) -> UIImage? {6 // Sync fallback for legacy paths (deprecated)7 return MainActor.assumeIsolated {8 self.legacyCacheStorage[url]9 }10 }11}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`
1// ❌ Wrong — JSON parsing blocks the main thread2@MainActor3final class APIClient {4 func fetchUsers() async throws -> [User] {5 let data = try await URLSession.shared.data(from: usersURL).06 return try JSONDecoder().decode([User].self, from: data) // 200ms main thread block!7 }8}9 10// ✅ Correct — only UI-bound method @MainActor11final class APIClient {12 nonisolated func fetchUsers() async throws -> [User] {13 let data = try await URLSession.shared.data(from: usersURL).014 return try JSONDecoder().decode([User].self, from: data)15 }16 17 @MainActor18 func updateUI(with users: [User]) {19 // UI update logic20 }21}Pitfall 2: Incorrectly capturing values in `@MainActor` closures
1Task { @MainActor in2 // This whole block runs on the main thread — don't do heavy work here!3 let processed = self.heavyProcessing() // main blocking!4 self.updateView(with: processed)5}6 7// ✅ Correct — heavy work in background, only UI update on main8Task {9 let processed = await self.heavyProcessing() // arbitrary executor10 await MainActor.run {11 self.updateView(with: processed)12 }13}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 60K-LOC codebase typically has 200+ closure-based APIs. Migrating all of them to async/await at once isn't feasible.
Bridge pattern:
1// Old callback API2func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {3 // legacy code4}5 6// Async wrapper7func fetchData() async throws -> Data {8 try await withCheckedThrowingContinuation { continuation in9 fetchData { result in10 continuation.resume(with: result)11 }12 }13}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?
1// ❌ Strict mode error2class AnalyticsTracker {3 static let shared = AnalyticsTracker() // mutable global state warning4 var sessionID: String = UUID().uuidString5}6 7// ✅ Solution 1: actor8actor AnalyticsTracker {9 static let shared = AnalyticsTracker()10 var sessionID: String = UUID().uuidString11 12 func setSession(_ id: String) {13 sessionID = id14 }15}16 17// ✅ Solution 2: immutable18final class AnalyticsTracker: Sendable {19 static let shared = AnalyticsTracker()20 let sessionID = UUID().uuidString // immutable, Sendable safe21 private init() {}22}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.
1// Old pattern2class LocationTracker: NSObject, CLLocationManagerDelegate {3 func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {4 // delegate callback5 }6}7 8// Modern pattern — AsyncSequence9class LocationTracker {10 var locations: AsyncStream<CLLocation> {11 AsyncStream { continuation in12 let bridge = LocationBridge { location in13 continuation.yield(location)14 }15 // CLLocationManager setup16 }17 }18}19 20// Usage — clean21for await location in tracker.locations {22 await viewModel.updateLocation(location)23}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.
1// Test double — Sendable conform2final class MockUserService: UserServicing, Sendable {3 let stubUsers: [User]4 5 init(users: [User] = []) {6 self.stubUsers = users7 }8 9 func fetchUsers() async throws -> [User] {10 stubUsers11 }12}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-hop overhead adds nanoseconds to every async call. After the 60K-LOC migration:
Metric | Pre-migration | Post-migration | Delta |
|---|---|---|---|
Launch time | 1.2s | 1.35s | +12% |
Memory baseline | 85MB | 91MB | +7% |
CPU under load | 12% | 14% | +17% |
Build time | 45s | 1m 12s | +60% |
Build time saw the biggest increase. Fix: incremental build caching + Xcode Cloud distributed builds.
Runtime overhead is acceptable. The async overhead is roughly ~50ns per actor hop — negligible for a mobile app.
Crash count, on the other hand, dropped 40% after migration — closing off data races measurably improved production stability.
10. Migration Checklist
The sequential checklist I followed for the production migration:
- **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. A 60K-LOC migration takes 3 months, so branch conflicts 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 enough that it cut runtime crash counts by 40%. But the migration isn't cheap — on a 60K-LOC codebase it took roughly 3 months of effort, build time went up 60%, and there's 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 3-month, 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:
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

