Distrito Telefónica. Innovation & Talent Hub

Back
Development

Survival Guide to migrate to Swift 6 without breaking your app


Over the past few months, our team has been working on migrating a complex project from Swift 5 to Swift 6 with strict concurrency checking fully enabled. It’s been a technically demanding journey, full of valuable lessons that I’m sharing here in hopes of helping other teams going through the same process. In this article, you’ll find practical reflections, common pitfalls, and real-world code examples that illustrate what we’ve learned.

Our Migration in Numbers

The project we migrated is an application with over 1,000,000 lines of Swift code, spread across 24 modules, with a heavy reliance on legacy concurrency patterns such as DispatchQueue, shared singletons, and non-isolated controllers. The migration was far from immediate: it required weeks of planning, a full dependency review, and extensive refactoring due to incompatibilities with Sendable and @MainActor.

During the process:

.   We added over 3,000 annotations of @MainActor or Sendable, and marked more than 400 types as @unchecked Sendable after verifying their manual synchronization was correct.

.   We had to isolate entire modules using @preconcurrency to make gradual progress without blocking other development streams.

.   And we adapted our testing setup, introducing Swift Testing for new modules while updating existing ones to use Swift Concurrency in XCTest.

The transition was progressive, tough, and full of gotchas, but in the end, we successfully migrated the entire project to Swift 6 without disrupting daily development or breaking existing features.

The Structural Challenges of Migrating

Migrating to Swift 6 is particularly challenging for large-scale projects that still rely on legacy concurrency patterns like DispatchQueue, NSOperationQueue, or custom thread management systems. These projects often contain thousands of lines of code not yet adapted to the structured concurrency model based on async/await or actors. Refactoring everything at once is unrealistic, and attempting to do so can break existing workflows, test setups, or internal APIs. Moreover, these legacy patterns don’t integrate naturally with the new Sendablerequirements or @MainActor isolation, leading to a flood of compiler errors and warnings. In such cases, balancing safety and pragmatism becomes essential — and that’s precisely what this guide aims to help with.

It’s worth noting that Swift 6 is a language in continuous evolution, and some of the issues or advantages mentioned here may become obsolete with future iterations. This article specifically refers to migration to Swift version 6.0.   @MainActor is not always the Main Thread

One of the most common mistakes when migrating to Swift 6 with strict concurrency is assuming that @MainActor automatically means your code is running on the main thread.

 Spoiler: it doesn’t.

So what exactly does @MainActor do?

@MainActor guarantees safe and exclusive access to the main actor but does not force the code to run on the main thread immediately.

This means that even inside an @MainActor function, you might not be on the main thread depending on the context.  
Example 1: simple call from a background queue

final class UIUpdater {
   @MainActor func showResult() {
        print("🔵 In UIUpdater.showResult — main thread?:", Thread.isMainThread)
    }
} final class Worker {
    let updater = UIUpdater()     func doWork() {
        DispatchQueue.global().async {
            print("⚪️ Starting in background — main thread?:", Thread.isMainThread)
            Task {
                await self.updater.showResult()
            }
        }
    }
}
Possible output:

⚪️ Starting in background — main thread?: false  
🔵 In UIUpdater.showResult — main thread?: false

Although showResult() is on an actor, it doesn’t automatically switch to the main thread. The system may run it on the current queue if there are no pending tasks for the actor on the main thread.  
Example 2: completion handler with @MainActor

final class Worker {
    let updater = UIUpdater()     func doWork() {
        DispatchQueue.global().async {
            simulateAsyncWork { result in
                print("🔴 Completion handler — main thread?:", Thread.isMainThread)
                self.updater.showResult()
            }
        }
    }     func simulateAsyncWork(completion: @escaping @MainActor (String) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            completion("Work finished")
        }
    }
}

Possible output:

🔴 Completion handler — main thread?: false  
🟢 showResult() — main thread?: false

Just like the previous example, even though completion is marked with @MainActor, it may not automatically switch to the main thread and may try to run on the current background thread.  
What about DispatchQueue.main.async?

Unlike @MainActor, an explicit call to DispatchQueue.main.async does guarantee the code will run on the main thread. This approach was very common before Swift Concurrency and is still a valid tool.

In fact, when the compiler detects this syntax, it assumes you’re on the main thread, allowing access to @MainActor properties or methods without warnings, even if the closure isn’t explicitly marked. This happens due to an internal optimization: the compiler marks these closures as "implicitly isolated to MainActor" when wrapped in DispatchQueue.main.async, RunLoop.main.perform, etc. This avoids concurrency errors without needing Task { @MainActor in } or await.

As Ole Begemann explains, this is a “promise from the programmer” to the compiler that the context is safe. But it’s not magic: the compiler doesn’t check at runtime if you're actually on the main thread — it just trusts that DispatchQueue.main ensures that. You need to make sure your DispatchQueue is properly configured and always launches on the main thread.

On the flip side, DispatchQueue.main.async doesn’t respect actor cooperativeness, which can lead to blocking or deadlocks if overused for heavy tasks. It doesn’t suspend the current task or release the actor until it finishes.

Conclusion: It’s useful as a tool for jumping to the main thread, especially within legacy code that hasn’t been adapted to the new actor or async/await ecosystem.  
MainActor.assumeIsolated: a useful (but dangerous) tool

Even though Swift 6 enforces concurrency safety, many native Apple APIs — including critical methods like awakeFromNib() or deinit for views isolated in @MainActor — are not marked with @MainActor, even though they always run on the main thread in practice.

This creates friction: if you try to access a MainActor-isolated property from one of these methods, the compiler will mark it as an error, even though it should always run on the main thread.

For these cases, Swift offers a tool to silence the compiler: MainActor.assumeIsolated { ... }. This lets you explicitly tell the compiler: “I know I’m on the MainActor, trust me.” Or rather, I know I’m on the main thread and there won’t be problems accessing this resource or instance.

@IBOutlet weak var label: UILabel!

override func awakeFromNib() {
    MainActor.assumeIsolated {
        label.text = "Hello from MainActor!"
    }
}

This doesn’t switch threads or isolate for you. It’s only valid if you’re really on the main thread — such as when initializing views from nibs or storyboards and the compiler can’t infer it.

Warning: using assumeIsolated is a promise to the compiler. If you’re wrong and not actually on MainActor or the main thread, you’ll run into runtime issues and your app may crash.
Isolate just enough: the lazy var trick with MainActor

One limitation with using @MainActor is that if you declare a class property like:

@MainActor let updater = UIUpdater()

Then the compiler forces the class's init to be actor-isolated as well:

@MainActor init() { ... }

This can be problematic, especially in classes without async methods or when you can't fully isolate a dependency in @MainActor without also isolating other classes that use it — propagating the isolation throughout the app.

A practical solution is to declare the property as lazy var. This delays its initialization until accessed, and only the method that uses it needs to be within MainActor context:

class ViewModel {
    @MainActor lazy var updater = UIUpdater()     func init() {}     @MainActor
    func updateUI() {
        updater.showResult()
    }
}

This keeps the architecture clean and minimizes unnecessary actor annotations.  
When you can’t rewrite everything: @unchecked Sendable to the rescue

In many projects, there are classes that already handle concurrent access using DispatchQueue, NSLock, or OperationQueue. These classes often have shared mutable properties like caches, services, or delegates, and adapting them to Sendable may be nearly impossible without rewriting much of the code.

This is where @unchecked Sendable comes in: a way to tell the compiler “trust me, I’ll make it safe.” It doesn't guarantee thread safety but allows compilation when you know you're already handling access correctly:

final class SessionManager:

@unchecked Sendable {
    private let syncQueue = DispatchQueue(label: "SessionManagerQueue")
    private var sessions: [String: Session] = [:]     func save(_ session: Session) {
        syncQueue.async {
            self.sessions[session.id] = session
        }
    }
}

This type of code is common in many apps. Instead of redesigning everything to be automatically safe with Sendable, you can document and mark it as @unchecked Sendable, retaining manual synchronization control and ensuring concurrency.  

Gradual migration with @preconcurrency

When working on an app split into multiple modules or frameworks, migrating everything at once to the new strict concurrency model is unfeasible — the error list may be endless. This is where @preconcurrency becomes essential.

This attribute lets you declare that a protocol, type, or import is meant to be used with code that doesn't yet follow the strict concurrency rules. By applying it, you avoid the compiler forcing you to annotate everything with Sendable or @MainActor all at once.

For example, you can mark a framework import that hasn’t been migrated yet:

@preconcurrency import LegacyNetworking

You can also use it in protocols used from new classes but not yet fully migrated:

@preconcurrency
protocol LegacyDelegate: AnyObject {
    func didFinish()
}

This is especially useful if you have classes that are @MainActor but must conform to legacy protocols. Without @preconcurrency, the compiler would force you to annotate the entire protocol as @MainActor too, which may not be viable yet.

Another common strategy is to use @preconcurrency in extensions that implement these protocols:

extension ViewController:

@preconcurrency LegacyDelegate {
    @MainActor func didFinish() {
        // running on MainActor
    }
}

This way, the compiler allows implementation within a @MainActor context, even if the protocol isn’t.

In short, @preconcurrency is key for layered migration: you can migrate module by module without breaking anything, gradually ensuring concurrency.  

XCTest and the limits with MainActor

One of the biggest obstacles when migrating to strict concurrency is the traditional testing system. XCTest was designed long before actors, and this causes serious conflicts when working with types annotated with @MainActor.  

Common XCTest issues

Some frequent limitations:

.   You can’t annotate setUp() or tearDown() with @MainActor because they override XCTestCase methods.

.   You can’t access @MainActor properties from those methods or create instances requiring it.

.   You can’t use await inside setUp() unless you use hacks like Task.runDetached or expectation.
 
Enter Swift Testing: built for modern concurrency

With Swift 6 and the new Swift Testing framework, we solve these issues:

.   You can mark the entire test or setUp() with @MainActor, no hacks needed.

.   You can use async let and await natively throughout the test phases.

.   You can define tests as async functions, without expectation or wait().

Example:

@MainActor
@testable import MyApp struct MyTests: Test {
    @MainActor var updater: UIUpdater     @MainActor
    func setUp() {
        updater = UIUpdater()
    }     func testButtonTitle() async {
        await updater.load()
        XCTAssertEqual(updater.title, "Welcome")
    }
}

This is not only cleaner: the compiler can verify isolation rules from the start, avoiding inconsistencies. Being part of the new model, Swift Testing supports native integration with @MainActor, Sendable, isolated, and other Swift Concurrency constraints.  
Conclusions: a strict path toward a safer (and more flexible?) future

Apple has gone all-in on safe concurrency in Swift 6. With the introduction of Sendable, MainActor, and strict compiler enforcement, the language has become one of the strictest in catching errors at compile time. While this is beneficial long-term, migrating real projects has become a complex, inflexible, and often frustrating task.

Tools like @unchecked Sendable, @preconcurrency, or even MainActor.assumeIsolated exist as temporary patches to make progress without rewriting entire codebases. But they are reactive solutions, often hard to discover, for a transition that many teams can’t tackle globally.

Swift 6 was designed as if we started from a perfect base: no shared mutability, no controllers, no singletons. But reality is different, especially in apps with years of history.  
The future? Likely to be gentler.

Based on Swift 6’s evolution, it’s likely the future will adopt a reverse philosophy: instead of assuming everything is concurrent and isolated, it will be assumed that UI code runs on the main thread unless stated otherwise.

This would greatly reduce the burden of code annotation, make test writing more natural, and bring Swift closer to what other languages already allow with less friction.

In the meantime, the road is demanding, but worth it. The rewards — more stable apps, fewer concurrency bugs, and better tools to ensure safety — are worth the effort. We just need Apple to also consider the teams still walking, not just those already running.