All articles
11 min·

5 Swift 6 myths busted directly by Apple

Notes from an Apple Q&A session on Swift Concurrency: 5 misconceptions about the Swift 6 migration, contradicted by the engineers from the compiler, Foundation and SwiftUI teams.

Par Carolane Lefebvre
iOSSwiftConcurrency
5 Swift 6 myths busted directly by Apple

Apple ran a Q&A session on Swift Concurrency two weeks ago. The panel was strong: Holly from the Swift compiler team (focused on the design of data race safety diagnostics), Jeremy from the Foundation team, Seema from the SwiftUI team (and an external contributor to the concurrency model before joining Apple), and Alan, a former framework developer on the compiler side.

Developers had submitted and upvoted around thirty questions. What struck me during the session was how often the Apple engineers contradicted things I had been hearing repeated for a year in the iOS community. Not out of malice, just because docs and blogs struggle to keep up with the language's evolution.

I kept the 5 myths that change the most about how you migrate an existing project. If you are pushing your Swift 6 migration back, or struggling with it right now, read to the end. There is a good chance one of these 5 points unblocks you.


Myth 1: Migrating to Swift 6 is one big project

This is probably the most expensive misunderstanding I have seen in iOS codebases this year. Everyone talks about "moving to Swift 6" as if it were a single action. In reality, and Holly hammered this several times during the session, these are two completely independent transitions:

  1. Modernise the code to use the native Swift Concurrency features. Concretely, transform your APIs to use async/await, actors, and so on. That often means rethinking part of the architecture.

  2. Enable strict concurrency checking and switch to Swift 6 language mode. That activates the compiler's static data race verification.

The classic trap is trying to do both at once. You end up trying to resolve hundreds of compiler diagnostics while fundamentally changing how your code is modelled. You can no longer tell whether a bug comes from the migration or the refactor. That is exactly the scenario that pushes teams to give up and postpone for 6 months.

Holly recommends two viable strategies:

  • Either you modernise the code first (without touching the language mode), verify behaviour stays correct via your tests, then enable Swift 6.

  • Or you turn the diagnostics on first and use unsafe opt-outs like @preconcurrency or nonisolated(unsafe) to express what is true of your code today, knowing you will come back to audit those points later.

Seema's tip that changes everything, and which I had read nowhere: before switching to Swift 6, first turn on strict concurrency checking in Swift 5. You get a complete preview of every diagnostic that will hit you on Swift 6, without breaking the build. It is a survey of the work before starting.

SWIFT_STRICT_CONCURRENCY = complete
SWIFT_VERSION = 5.10

Run a full build, look at the warnings in the Issue Navigator, categorise them. Within an hour, you will know whether your migration is 2 days or 2 weeks of work.

Another freeing point from the session: you can turn it on and off. No one forces you to finish in one go. Turning on diagnostics, making some progress, turning them off for a release that has to ship, is fine. And legacy code that has been stable for three years does not need to be migrated right away. The real win of Swift 6 is having static guarantees on the new code you write.


Myth 2: A Task is automatically cancelled when its owner goes away

That one cost me a production crash a few months ago, and apparently I am not the only one.

The natural reflex, especially coming from Combine or callbacks, is to assume a Task follows the lifecycle of the object that started it. When self is deallocated, the Task should stop, right?

No. The Apple engineers were very clear on this during the session. Two mechanics combine.

First, cancellation in Swift Concurrency is cooperative. That is intentional and a key thing to grasp. When you call .cancel() on a Task, it does not immediately stop the running code. It is up to the code to regularly check whether it has been cancelled, via Task.isCancelled or try Task.checkCancellation(), and act accordingly (typically throwing CancellationError).

It is intentional because synchronous code doing atomic cleanup must not be interruptible at any moment. Imagine a CoreData transaction in the middle of a commit, stopped halfway: guaranteed inconsistency.

Second, and this is the trap: Tasks are not automatically cancelled when the scope owning them is deallocated. If you launch a Task from a ViewModel and the ViewModel dies, the Task keeps running quietly in the background. If it captures self, it even prevents the deallocation.

If you want auto-cancellation on deinit, you have to code it yourself:

@MainActor
final class MyViewModel {
    private var loadTask: Task<Void, Never>?

    func startLoading() {
        loadTask?.cancel()
        loadTask = Task {
            await loadEverything()
        }
    }

    deinit {
        loadTask?.cancel()
    }
}

Note: SwiftUI's special case. The .task modifier automatically cancels its task when the view goes away. But SwiftUI does not expose the handle of that task. So if you need fine-grained control over cancellation (for example to guarantee no UI update happens after dismiss), you have to give up .task and use .onAppear + .onDisappear with your own Task stored in @State.


Myth 3: @MainActor guarantees my code runs on the main thread

On iOS and macOS, yes. For 99% of what you write, you can treat it as true.

But this session highlighted two important nuances few iOS devs know.

First, @MainActor is not synonymous with main thread in the language. It is a particular actor that, on Apple platforms, is mapped to the main dispatch queue. On other platforms (Linux, server-side Swift, for example), @MainActor could technically be another thread. If you are writing a cross-platform library, that detail matters.

Second, and this is the real trap: you can bypass the guarantee through C, Objective-C or C++ interop, where strict checking does not apply. If you call a @MainActor-isolated function from a C context without strict checking (typically a low-level callback, a CoreFoundation notification, a POSIX call), you can end up running it off the main thread, potentially creating a data race the compiler could not see.

It is rare but it is exactly the kind of bug that crashes under TSan in CI without anyone understanding why. Swift offers dynamic checking tools to catch these cases at runtime if you need them, notably via MainActor.assertIsolated() or the Thread Sanitizer flags in the scheme.

If you maintain legacy code with lots of C or Obj-C bridges (typically wrappers around low-level Audio, Video or Network APIs), this is a high-priority audit area.


Myth 4: You should always put weak self in a Task

A Combine reflex I see copy-pasted everywhere, including in code reviews I stepped into recently. Alan was the most blunt during the session: it depends, and it is often a bug.

The weak self pattern in tasks is a habit inherited from callbacks and Combine, where subscriptions could survive forever and create reference cycles. In Swift Concurrency, it is not always necessary, nor even beneficial.

Cases where weak self is legitimate:

  • A task launched from a view, that should not keep it alive after disappearing.

  • More generally, when you know you do not want the task to extend the life of self.

Cases where weak self is inadequate and can introduce subtle bugs:

  • A task that absolutely must finish its work before self goes away. Typically: a cleanup, a save, a transaction, a critical upload. You actually want it to keep self alive long enough to finish.

A concrete example. In a checkout ViewModel, you launch a task to finalise an order:

func confirmOrder() {
    Task { [weak self] in
        guard let self else { return }
        try await api.submit(self.order)
        self.state = .confirmed
    }
}

If the user closes the screen during the request, self can be deallocated, the guard fails, and your order is never submitted. Without weak self, the Task would have kept the ViewModel alive long enough to finish, and the order would have gone through. That is the exact opposite of what you want.

The complementary pitfall mentioned by Seema: infinite tasks. If you have an infinite loop inside a Task and you store the Task on self, you create a reference cycle. weak self can help, but the real fix is often to structure differently, with explicit cancellation at the right time.


Myth 5: nonisolated async moves to the concurrent pool

If you read Swift code from 2022-2023 or articles from that era, that was true. Today, it is the opposite. And many people did not get the memo.

Apple eventually decided the historical default behaviour (async functions automatically moved onto the concurrent thread pool when called) was a bad idea, because it introduced implicit concurrency. You felt like you were writing linear code, but in reality every await could change your isolation without you realising.

The new default behaviour when you enable the approachable concurrency settings is: nonisolated(nonsending). Your async function stays in the caller's isolation, unless there is an explicit reason to leave it. It is a huge change in the language's semantics.

To express the old behaviour (explicit offload to the concurrent pool), you now have to mark the function @concurrent:

@MainActor
final class MyViewModel {
    var items: [Item] = []

    @concurrent
    func processFiles(_ files: [URL]) async {
        // Runs on the concurrent pool, not the main actor.
        // The compiler prevents accessing `items` directly here.
    }
}

The compiler will prevent direct access to the type's mutable @MainActor properties from this @concurrent method, which guarantees the absence of data races. To update items, you will have to explicitly hop back to the main actor.

That is exactly the kind of pattern that makes Swift 6 viable for apps with heavy ViewModels: you no longer need to push everything off the main actor, you just mark the IO or parsing method that needs it.

Apple's recommendation: always enable the approachable concurrency settings. And use @concurrent when you really want to offload.


Concrete action plan to apply all this

This session changed how I approach Swift 6 on my projects. Here are 5 actions I recommend, in order, if you want to turn this read into a solid skill.

1. Audit your project without breaking anything

Enable strict concurrency checking in Swift 5 in your build settings (without switching to Swift 6). Run a full build. Count the warnings, categorise them. You get your terrain map. If you have 0 or 5, you are in good shape. If you have 200, there is work to do but the effort is measurable.

2. Identify your largest unnecessary actor

Look in your code for actors you introduced out of habit, not necessity. For each actor, ask: does it have internal mutable state that is genuinely shared between concurrent tasks? If not, the actor is probably unnecessary. Simplifying it into a struct or a @MainActor class will reduce your await boilerplate everywhere.

3. Get familiar with @concurrent

Take one of your @MainActor ViewModels that contains a heavy IO or parsing method. Add @concurrent on that specific method. Watch what the compiler forces you to do: you cannot access mutable properties directly, so you have to explicitly hop back onto @MainActor to update the UI. Excellent mental exercise that anchors the semantics of isolation.

4. Refactor a problematic @Sendable closure

If you have a closure marked @Sendable where you struggle with captures, see if you can replace it with a sending closure (one-shot transfer) instead of @Sendable (callable multiple times). That is often the right answer when the closure is called only once, like in Tasks.

5. Read two Swift Evolution proposals

Go to swift.org/swift-evolution and look at proposals "implemented in Swift 6.4". Read in full the withDeadline proposal (tasks with timeout) and the one that adds the diagnostic for errors swallowed by tasks. Not to learn them by heart, but to get used to the proposal format. The language evolves every quarter, reading the proposals keeps you up to date better than any article.


Going further

If you are starting a Swift 6 migration on a sizeable project and you want an outside view on your audit or your architecture choices, that is exactly the kind of mission I take on as a freelancer. Reach out, let us talk.

And if this article saved you a day of pain, share it with another iOS dev in the same situation. It will help them.

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

More articles

.task vs .onAppear vs .refreshable en SwiftUI

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI