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
actoror@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.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

