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
Click Run in unprotected mode repeatedly. The result is different every time, and always wrong. Switch to actor protected mode: the result is always correct. Same operations, same threads, different guarantees.

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.

Renala volume selection screen showing available volumes while the app is idle, ready to scan
The volume list is always interactive. While a scan runs on background tasks, the main thread remains free for UI updates, animations, and user input. Swift 6 makes this correctness mandatory, not optional.

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):

Written reference: