DEV Community

Cover image for Swift 6 Strict Concurrency: 4 Migration Mistakes and How to Fix Them
Mrugesh Tank
Mrugesh Tank

Posted on

Swift 6 Strict Concurrency: 4 Migration Mistakes and How to Fix Them

I enabled Swift 6 mode on a project I'd been shipping for two years. Sixty-plus errors. My first reaction was to roll it back.

I'm glad I didn't. At least a dozen of those were real bugs — data races and actor isolation violations that had been silently waiting to bite me in production.

Here are the four patterns behind most of those errors, and exactly how I fixed them.


1. Passing non-Sendable types into a Task

This is the most common one. You have a class — say, a ProfileViewModel — and you capture it inside a Task closure without thinking about what that means for thread safety.

// ❌ Swift 6 error: capture of 'viewModel' with non-sendable type
class ProfileViewModel {
    var username: String = ""
}

Task {
    await fetchProfile(for: viewModel)
}
Enter fullscreen mode Exit fullscreen mode

ProfileViewModel is a reference type with mutable state. Two contexts can now read and write it at the same time. That's a data race.

The cleanest fix is converting to a struct — value types get Sendable for free:

// ✅ Sendable by default
struct ProfileViewModel: Sendable {
    let username: String
}
Enter fullscreen mode Exit fullscreen mode

If you genuinely need reference semantics and mutable state, reach for an actor instead. And if you're wrapping something thread-safe that predates Swift concurrency, @unchecked Sendable is fine — but document why it's safe.


2. Accessing @MainActor properties from an unstructured Task

You fetch some data in a Task, then try to update a @MainActor property directly. Swift 6 blocks it.

// ❌ Task doesn't inherit @MainActor automatically
func loadFeed() {
    Task {
        let fetched = await FeedService.shared.fetchPosts()
        posts = fetched // ⚠️ isolation violation
    }
}
Enter fullscreen mode Exit fullscreen mode

The fix depends on the class. If your view model or view controller is entirely UI-bound, just annotate the whole class with @MainActor:

// ✅ Cleanest option for UI-bound types
@MainActor
func loadFeed() {
    Task {
        let fetched = await FeedService.shared.fetchPosts()
        posts = fetched // fine — already on @MainActor
    }
}
Enter fullscreen mode Exit fullscreen mode

Otherwise, hop back explicitly:

await MainActor.run {
    posts = fetched
}
Enter fullscreen mode Exit fullscreen mode

KISS applies here. The simpler your isolation model, the harder it is to get wrong.


3. Using nonisolated and @unchecked Sendable as escape hatches

When you have 60 errors, these keywords look like lifelines. One annotation and the red goes away.

The compiler goes quiet. The underlying problem doesn't.

Both have legitimate uses — nonisolated is right for actor methods that don't touch mutable state, and @unchecked Sendable is fine for manually thread-safe types like a lock-protected cache. But if you can't immediately explain why something is safe, that's the signal to fix the data flow, not suppress the warning.

I've seen codebases where every model class was @unchecked Sendable. Every single one was a ticking time bomb.


4. Assuming async functions inherit actor context

This one is subtle and it's easy to miss, because it behaved fine in Swift 5.

If you have a @MainActor class that calls an async helper, the helper does not automatically run on the main actor:

// ❌ loadTitle runs on a generic executor, not @MainActor
@MainActor
class DashboardViewModel: ObservableObject {
    @Published var title: String = ""

    func refresh() {
        Task { await loadTitle() }
    }
}

func loadTitle() async {
    let result = await TitleService.fetch()
    title = result // ⚠️ Swift 6 error
}
Enter fullscreen mode Exit fullscreen mode

The async keyword doesn't carry context forward. Task { } starts fresh.

Fix it by annotating the function directly:

// ✅ Explicit is always better than implicit
@MainActor
func loadTitle() async {
    let result = await TitleService.fetch()
    title = result
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: if a function writes to @Published properties, it belongs on @MainActor. Define the contract at the function — don't scatter it across every call site.


The migration approach that actually worked for me

Don't turn on Swift 6 mode everywhere at once. Here's the order that worked:

  1. Set SWIFT_STRICT_CONCURRENCY = targeted in build settings first. It surfaces the critical issues without the full error count.
  2. Write all new modules in Swift 6 mode from day one.
  3. Fix Sendable errors first, then actor isolation, then clean up any suppressions.
  4. Replace print with Logger as you touch files — signals intent, not just patching.
import OSLog
private let logger = Logger(subsystem: "com.yourapp", category: "Migration")
logger.debug("Actor isolation resolved for FeedViewModel")
Enter fullscreen mode Exit fullscreen mode

You'll learn more from 20 well-understood fixes than 200 suppressions.


Full article with diagrams and a complete reference list on the blog 👉 Swift 6 Strict Concurrency: Common Migration Mistakes and How to Fix Them


Part 3 of the Swift Concurrency in Production series on idiotswithios.com

Top comments (0)