Swift 6 Strict Concurrency - Eliminate Data Races and Boost iOS Performance
18 Aug 2025Swift 6âs strict concurrency isnât a ânice to have.â Itâs the cleanest, most scalable way to kill data races in your app â at compile time. If you flip it on and actually fix the warnings (donât silence them), you get a codebase thatâs easier to reason about, safer across teams, and faster under load.
Below is a practical, opinionated guide to turn it on, fix the fallout, and come out with a better architecture. Code snippets included.
Turn it on, incrementally, and surface the truth
My take: start with warnings, then graduate to errors. You want signal without breaking your CI on day one.
Xcode projects
- In Build Settings â Strict Concurrency Checking, set to Complete. (Equivalent to
SWIFT_STRICT_CONCURRENCY = completein an.xcconfig)
SwiftPM (while still on Swift 5 language mode)
// Package.swift (Swift 6 tools or later)
.target(
name: "AppCore",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency")
]
)
This enables complete actor-isolation and Sendable checking as warnings so you can see the blast radius.
Swift 6 language mode
- Xcode: Swift Language Version â 6 (per target).
- SwiftPM: using
// swift-tools-version: 6.0will build targets in Swift 6 language mode by default (you can still override per target).
Only move a module to Swift 6 mode after youâve burned down warnings in that module.
Fix Sendable the right way
Principle: data that crosses concurrency domains must be provably safe to share.
Prefer value types (structs, enums)
If a type is immutable or only holds Sendable fields, the compiler will often infer Sendable. If not, declare it explicitly:
public struct UserSnapshot: Sendable {
public let id: UUID
public let name: String
public let avatarURL: URL?
}
Avoid sprinkling @unchecked Sendable
Use it as a last resort and leave a TODO with a plan to remove. Wrap legacy reference types behind an actor or make them immutable.
final class LegacyThing { /* not thread-safe */ }
// Temporary: contain it
actor LegacyThingBox {
private let inner = LegacyThing()
// expose async methods that serialize access to `inner`
}
Fix âcapture of non-Sendable in @Sendable closureâ
Donât pass around mutable classes inside parallel tasks. Either:
- Snapshot the data into a
Sendablevalue, or - Give the object an actor âfront door,â and call it with await.
This is where you pay down the technical debt youâve been ignoring. Itâs worth it.
Actor isolation: be intentional with @MainActor
Hot take: too many projects mark entire view models as @MainActor and then complain the UI is slow. Mark only what must run on the main thread.
UI types and UI entry points
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var name: String = ""
// UI-initiated action (touches UI state) â keep it on main actor
func load() async {
// <- cross-actor hop
let user = await userService.currentUser()
name = user.name
}
}
Use nonisolated to avoid unnecessary hops
If something is pure (no access to isolated state), make it nonisolated so it can run anywhere without the main-actor hop:
@MainActor
final class ProfileViewModel: ObservableObject {
private let formatter = PersonNameComponentsFormatter()
@Published private(set) var name: String = ""
// Pure helper: no `self` or isolated state
nonisolated func format(_ user: UserSnapshot) -> String {
user.name.uppercased()
}
func update(_ user: UserSnapshot) {
name = format(user) // no hop, no await
}
}
Rules of thumb
@MainActoron UI entry points (rendering,@Published, SwiftUI actions).nonisolatedfor fast paths â formatting, hashing, mapping â as long as they donât touch isolated state.- Avoid
nonisolated(unsafe)unless you truly know what youâre doing and youâre protecting access externally (locks, actors). Treat it as a temporary escape hatch while migrating.
Adopt global actors for shared subsystems
When many types must serialize access to the same resource (disk cache, analytics pipeline, database), global actors shine. They give you a single, app-wide executor for that subsystem â no copy-paste DispatchQueues, no hand-rolled locks.
Define a global actor
@globalActor
actor StorageActor {
static let shared = StorageActor()
}
This makes @StorageActor a usable attribute across your codebase.
Isolate a subsystem to it
@StorageActor
final class DiskCache {
private var index: [String: URL] = [:]
func store(_ data: Data, for key: String) throws {
let url = /* compute file URL */
try data.write(to: url)
index[key] = url
}
func load(_ key: String) -> Data? {
guard let url = index[key] else { return nil }
return try? Data(contentsOf: url)
}
}
Now any call into DiskCache synchronizes through StorageActor.shared. You can mark other types (ImageDatabase, FileMover) with @StorageActor and theyâll all share the same serialized executor. This is a clean, declarative way to guarantee âonly one thing touches the disk at a time.â
When to use a global actor vs plain actor?
- Plain actor: per-instance isolation (each instance protects its own state).
- Global actor: one shared executor for the whole subsystem. Use it when multiple types must coordinate.
Protocols, cross-module code, and migration tactics
Strict concurrency will uncover friction where protocols or 3rd-party APIs werenât annotated.
Tips
- If a protocol requirement must be callable off the main actor, donât force
@MainActoron the entire conforming type. Consider anonisolatedrequirement or move the isolation to call sites. - For older dependencies, you can temporarily use
@preconcurrency import SomeFrameworkto adopt without tripping over missing annotations â then file issues or PRs upstream to add properSendable/actor isolation. (Use sparingly; prefer fixing types.) - If you have global mutable state (singletons,
static var), either make it immutable (static let) or move it behind an actor/global actor. Avoid usingnonisolated(unsafe)as a permanent fix.
Concrete before/after examples
Example A: Non-Sendable capture â value snapshot
Before
final class Session { var token: String } // not Sendable
let session = Session()
Task.detached { // runs concurrently
// warning: capture non-Sendable
await api.fetchProfile(token: session.token)
}
After
// snapshot the value
let token = session.token
Task.detached {
await api.fetchProfile(token: token) // ok: String is Sendable
}
Example B: UI work vs background work
@MainActor
final class PhotosViewModel: ObservableObject {
@Published private(set) var thumbnails: [UIImage] = []
func refresh() async {
// hop off main actor to do CPU work
// not main-isolated
let images = await ImagePipeline.shared.loadThumbnails()
thumbnails = images // back on main
}
}
@globalActor
actor ImagePipeline {
static let shared = ImagePipeline()
func loadThumbnails() async -> [UIImage] {
// decode/resize here; serialized by the pipeline actor
}
}
Example C: Nonisolated fast path inside a main-actor type
@MainActor
final class FormatterVM {
nonisolated func formatCount(_ n: Int) -> String {
NumberFormatter.localizedString(
from: n as NSNumber, number: .decimal)
}
func updateLabel(_ n: Int) {
label = formatCount(n) // no actor hop, no await
}
@Published private(set) var label = ""
}
Ensure the helper is actually pureâno shared global DateFormatters unless theyâre isolated.
Team workflow that works
- Flip on âStrict Concurrency: Completeâ across modules to surface all warnings. Track counts in CI.
- Triage by category:
Sendablefirst (easy wins with value types), then actor-isolation fixes (@MainActor,nonisolated, global actors). - Cut tickets per dependency: libraries missing annotations? Fork or open PRs.
- Move modules to Swift 6 language mode once warning-free. Repeat until everything is in Swift 6.
Pitfalls to avoid
- Blanket @MainActor on âeverything UI-ish.â That kills concurrency. Be precise.
- @unchecked Sendable everywhere: Itâs a liability; aim to remove it.
- nonisolated(unsafe) as a permanent solution: It disables protectionsâuse it only as a temporary bridge with clear exit criteria.
Why this makes your app faster
Strict concurrency isnât just safety theater. By isolating subsystems and removing accidental main-actor hops, you:
- Reduce contention on the main thread â smoother scroll and animations.
- Avoid hidden locks and GCD pyramids â less overhead, fewer context switches.
- Make parallelism explicit â e.g.,
async let+ actors scale cleanly on modern CPUs.
Final nudge
Turn it on. Fix the warnings. Introduce global actors where you share mutable state. Use @MainActor sparingly and nonisolated aggressively for pure, fast paths. Youâll end up with code that reads like it intends to run correctly â not code that hopefully does.