
Swift 6 and the Concurrency Cliff
The compiler told me my code was wrong. It was right.
I already knew about data races. Any language with real threads will teach you that. The main thread is special, touching UI from a background thread is wrong, and race conditions are the kind of bug you find at 11pm after two hours of staring at code that looks correct.
I also knew async/await. The syntax is nearly identical across Swift, JavaScript, Python: you write async, you write await, the function suspends and the runtime does something else. I read the Swift concurrency documentation and thought: I know the threading model, I know the syntax. This will be fine. Famous last words.
What I did not expect was the compiler enforcing it.
The difference a compiler makes
With GCD, thread safety is a convention. You dispatch to the right queue, you use the right locks, and if you forget, the app crashes at runtime, or worse, produces wrong results silently. The compiler does not know or care. It compiles whatever you write.
Swift’s concurrency model is built on the same multi-threaded reality: multiple tasks running simultaneously on different CPU cores, shared mutable state as the source of all trouble. But Swift 6 adds one thing GCD never had: the compiler checks the rules statically. Not a warning. A compile error.
For readers coming from JavaScript rather than Objective-C, the contrast is even starker: JS async/await is single-threaded, so data races are structurally impossible. Swift async/await looks identical on the surface but runs on a thread pool.
graph TD
subgraph JavaScript
JET["Event loop<br/>(single thread)"]
JET -->|"await suspends,<br/>returns to loop"| JET
JET --> JUI["All code runs here<br/>No data races possible"]
end
subgraph Swift
ME["Swift executor<br/>(thread pool)"]
ME --> T1["Task 1<br/>CPU core A"]
ME --> T2["Task 2<br/>CPU core B"]
ME --> T3["Task 3<br/>CPU core C"]
T1 & T2 & T3 -->|"shared mutable state?"| RACE["Data race<br/>💥"]
end
The first time I enabled Swift 6 strict concurrency in the project, I got roughly forty errors. In a codebase I thought was correct.
The three categories of errors
The errors fell into a few categories.
Category 1: UI state accessed from a background task. The scanner runs on a background thread. The progress bar reads from the ViewModel on the main thread. Early on, I wrote code that called back into the ViewModel directly from inside the scanner task, mutating properties that SwiftUI was actively reading to render the progress UI. In JavaScript this would have worked fine: single thread, no conflict. In Swift, on a multi-threaded executor, this is a data race.
Swift 6 made it a compile error: expression is not concurrency-safe because it accesses 'x' from outside the actor.
Category 2: Non-Sendable types crossing actor boundaries. Sendable is a protocol: think of it as a TypeScript interface that means “this type is safe to share between threads.” Structs are Sendable by default if all their stored properties are Sendable. Classes with mutable state are not, unless you manually guarantee the safety.
I had a class I was passing into a TaskGroup closure. TaskGroup is Swift’s way of running several tasks in parallel and collecting their results, similar to Promise.all but with structured scoping: results flow back to the caller in order, and the group cannot outlive the enclosing scope. The class had mutable properties. The compiler refused: type is not Sendable.
Category 3: @MainActor isolation violations. @MainActor is a special built-in actor that represents the main UI thread: a singleton, always the same thread, the one SwiftUI renders on. Marking a type or function @MainActor means it can only execute there. SwiftUI views are @MainActor. My ViewModels were @MainActor. Any code that mutates ViewModel state must be @MainActor. The compiler tracks this statically.
graph LR
BG["Background Task<br/>(any thread)"]
MA["@MainActor<br/>(main thread only)"]
VM["ScanViewModel<br/>@MainActor @Observable"]
UI["SwiftUI View<br/>@MainActor"]
BG -->|"direct mutation ❌<br/>compile error"| VM
BG -->|"await MainActor.run ✅"| MA
MA --> VM
VM -->|"@Observable tracking"| UI
The pattern that fixes all three
The fix is straightforward: separate computation from state mutation.
Pure computation does not need actor isolation. Reading a file size, hashing bytes, building a data structure from raw memory: none of this touches shared state. These operations can run anywhere, on any thread, without synchronization. You mark them nonisolated static: static because they belong to the type rather than an instance (familiar from JavaScript classes), nonisolated because they carry no actor requirement and the compiler can place them on any thread freely.
State mutation is different. Anything that touches the ViewModel, anything that SwiftUI reads, must happen on @MainActor. You collect results from the background work and bring them home in a single hop.
// Extract I/O work into a nonisolated static helper
private nonisolated static func readFileSize(at path: String) throws -> Int64 {
// pure computation, no shared state
var stat = stat()
guard lstat(path, &stat) == 0 else { return 0 }
return Int64(stat.st_size)
}
// In an actor method, call it from TaskGroup
await withTaskGroup(of: (String, Int64).self) { group in
for path in paths {
group.addTask {
let size = (try? Self.readFileSize(at: path)) ?? 0
return (path, size)
}
}
for await (path, size) in group {
await MainActor.run { self.updateSize(path: path, size: size) }
}
}
The data flows in one direction:
graph LR
PATHS["Input: paths[]"] --> TASK["TaskGroup<br/>addTask per path"]
TASK --> WORKERS["N parallel workers<br/>nonisolated static helpers"]
WORKERS -->|"pure I/O<br/>no actor context"| RESULTS["Results<br/>(String, Int64) tuples"]
RESULTS -->|"for await"| COLLECT["Collect on actor"]
COLLECT -->|"MainActor.run"| STATE["ViewModel state update<br/>@MainActor"]
STATE --> SWIFTUI["SwiftUI re-render"]
The TaskGroup closures can call Self.readFileSize because it is nonisolated static. No actor context required.
Actors
Renala needs to hash files to find duplicates. Six worker tasks run in parallel, each reading and hashing a different file. All six write their results to the same shared state. Without protection, this is a textbook data race.
The solution is an actor: a reference type with a built-in mutex. Only one task can execute inside an actor at a time. If two tasks try to call methods on the same actor simultaneously, one waits. No locks. No manual thread management. The compiler enforces the isolation.
The HashVerificationService in Renala is an actor. It manages the six-worker pool, accumulates hash results, and exposes them to the rest of the app through await calls. External code suspends while the actor is busy and resumes when the call completes.
The tradeoff: every call into an actor from outside is asynchronous. You cannot call actor.someMethod() without await from a different concurrency context. Occasionally annoying. Never wrong.
The practical result
All of this machinery serves one goal: the UI stays responsive while the scanner runs.

A full disk scan touches 5.2 million files. That work takes over a minute. If any of it lands on the main thread, the UI freezes. Every iOS or macOS app you have ever used where scrolling stutters during a background operation has this exact bug: blocking work on the main thread. Swift 6 makes that a compile error rather than a runtime symptom.
What the compiler caught that tests would have missed
The bugs were real. If I had shipped the code with those compile errors suppressed, there would have been race conditions. Some visible: corrupted UI state, crashes. Some invisible: slightly wrong scan counts, progress indicators that jumped backwards. The kind of bugs you investigate for days and never reproduce consistently, because race conditions are non-deterministic. They depend on which thread runs first, which changes every time.
Swift 6 made them errors at compile time, before any testing, before any user ever ran the code. The price is a learning curve. The compiler’s vocabulary (Sendable, @MainActor, actor isolation) is not small. The first forty errors felt like an indictment of my competence.
They were not. They were a to-do list. Every one of them pointed at a real problem. Fix the problems, the errors go away, the code is correct by construction. The last App Store submission had zero concurrency-related crashes.
I stopped fighting it about a week in. After that, the compiler became the best code reviewer I have ever had. It does not get tired, it does not miss things, and it does not care about your feelings.
Going further
Swift concurrency has more depth than one post can cover. These are the resources I found most useful:
WWDC sessions (free, official):
- Protect mutable state with Swift actors: WWDC 2021. The clearest explanation of the actor model, with live code examples.
- Meet async/await in Swift: WWDC 2021. The foundation. Worth watching even if you know async/await from another language.
- Eliminate data races using Swift Concurrency: WWDC 2022. Directly addresses the categories of errors Swift 6 catches, before they became mandatory.
- Migrate your app to Swift 6: WWDC 2024. Practical migration guide for exactly this situation: an existing codebase hitting the strict concurrency wall.
Written reference:
- The Swift Programming Language: Concurrency: the canonical reference. Dense but complete.
- Swift concurrency by example: Paul Hudson’s practical guide at Hacking with Swift. Well structured, lots of short focused examples.