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 withtargeted. 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
Practical 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
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
}
Common Sendable pitfalls:
-
[String: Any]—Anyisn't Sendable; use a typed dictionary -
URL— 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
// 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)
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]
}
}
}
[[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
}
}
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)
}
}
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)
}
}
}
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() {}
}
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)
}
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
}
}
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
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. 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:
- Apple Swift Concurrency Migration Guide
- Swift Evolution SE-0337
- Strict Concurrency Performance Benchmarks (Point-Free)
Originally published at muhittincamdali.com.
Top comments (0)