Migrating to Swift 6: The Strict Concurrency You Must Adopt

Share This Post

When developing applications, one of the biggest challenges is managing multiple tasks simultaneously without the end-user noticing wait times or, worse, unexpected errors. Concurrency, which allows multiple operations to run simultaneously, has always been a complex topic in development. Swift, as a programming language, has gradually improved this aspect, and with the arrival of Swift 6, Apple aims to take a decisive step with Strict Concurrency.

Since Swift was released in 2014, the language has continuously evolved, becoming safer and easier to use. In Swift 5, async/await was a significant addition that made asynchronous code easier to write, making tasks like data downloading or UI updates more manageable and secure.

Now, with Swift 6 on the horizon, Apple has set out a series of improvements and changes that, while not immediately mandatory, will become the standard to follow in the coming years. From April 2025 onwards, all apps published on the App Store must be compiled with the iOS 18 SDK. Although this doesn’t directly require the use of Swift 6, it’s a reminder that developers must be ready to adopt the latest tools and features, especially concerning concurrency.

For many, this means that migrating to Swift 6 should be a priority. The change will not only improve the security and performance of apps but also prevent future problems related to concurrency management by harnessing the power of async/await other improvements. If you’re working on large projects or relying on third-party SDKs, it’s essential to start planning the transition as soon as possible.

What is Strict Concurrency in Swift 6?

If you’ve been developing in Swift for some time, you’ve probably heard of concurrency and how it can sometimes be a headache, especially when managing multiple tasks simultaneously. For a long time, we’ve been using tools like DispatchQueue or OperationQueue to run tasks in parallel. While these tools have allowed us to do a lot, they’ve also caused more than a few issues when something didn’t work as expected.

With Swift 6, Apple is giving us a big hand with what they call Strict Concurrency. In short, it provides us with clearer rules and safer tools to ensure concurrent code doesn’t become an unpredictable mess.

What does this mean in practice?

Before Swift 5, if we wanted part of our code to run in parallel, we’d launch those tasks with DispatchQueue or something similar. But this could quickly become confusing, and if we weren’t careful, we could end up with race conditions, unsafe access to shared variables, and all kinds of problems that caused our apps to fail unexpectedly.

In Swift 5, Apple introduced async/await, an easier and cleaner way to handle asynchronous tasks. Instead of managing threads and queues manually, async/await lets us write code that looks sequential, even though it’s doing many things at once.

Now, in Swift 6, Apple is pushing even harder for the use of async/await, and while it’s not strictly mandatory just yet, the idea is that we start adopting it as the de facto standard. If we want to take advantage of all the new features in Swift 6, we will likely have to use async/await in much of the code.

Let’s talk about Actors

Another major change in Swift 6 is the introduction of actors. What are actors? Simply put, they are like little safes that protect our data. Imagine having a variable that can be modified by several tasks simultaneously. Previously, we had to manually ensure that two tasks didn’t access that variable at the same time, which was a headache.

With actors, Swift 6 takes care of that for us. Actors ensure that only one task can modify a variable at a time, making race conditions a thing of the past (or at least much less likely).

Here is a quick example to clarify:

Before Swift 6, we might have written something like this to handle concurrency:

swift
actor Counter {
    var value = 0
    
    func increment() {
        value += 1
    }
}

let counter = Counter()

Task {
    await counter.increment()
}

The problem here is that if multiple tasks access counter at the same time, we might end up with incorrect values. However, with Swift 6, we can encapsulate that same counter in an actor:

swift
actor Counter {    
var value = 0        
func increment() {        
value += 1    
}
}
let counter = Counter()
Task {    
await counter.increment()}

This actor ensures that only one task at a time can modify value, saving us a lot of headaches.

  Migrating Retrofit to Ktor

Fewer Errors, More Security

What Apple wants with Strict Concurrency is for us to stop worrying so much about those concurrency errors that used to appear when we least expected them. By pushing us to use tools like async/await and actors, they’re making our apps more secure and reliable. And while it’s not something we need to implement right away, the sooner we start adopting these new practices, the better prepared we’ll be for the future.

Impact on Legacy Code

Migrating a legacy project to Swift 6 with Strict Concurrency requires a series of changes that aren’t always obvious at first glance. Beyond adjusting some threads or switching DispatchQueue to async/await, there are deeper elements that can cause problems if not managed properly. Below are some key aspects to consider when approaching migration.

Singletons and Global Properties

Singletons and Global Properties are common in legacy projects. While both approaches share the idea that a single instance or variable can be accessed from anywhere in the code, they are also vulnerable to concurrency issues.
In Swift 6, any shared access to an instance or property from multiple threads can cause race conditions if not handled properly. Previously, we could declare something like this:

swift

class NetworkManager {

static let shared = NetworkManager()

private init() {}

func fetchData() {

// Network code

}

}

This Singleton wasn’t protected against concurrent access, which could lead to hard-to-detect errors. With Strict Concurrency, you need to encapsulate both Singletons and global properties within actors to ensure safety in concurrent environments.

Migrating to a safe actor:

swift
actor NetworkManager {
    static let shared = NetworkManager()
    private init() {}

    func fetchData() {
        // Safe network code
    }
}

Actors ensure that only one thread can access these shared resources at a time, eliminating the need for manually managing synchronization.

Dependency Injection

If your legacy project uses a dependency injection pattern, it’s crucial to review how the injected instances are managed in a concurrent environment. In earlier projects, it was common to share dependencies across several threads without additional protection. However, with Strict Concurrency, shared dependencies need to be accessed safely.

An example would be having a shared class injected into various parts of your code:

swift
class DatabaseManager {
    func saveData() {
        // Save data to database
    }
}

let dbManager = DatabaseManager()

func processData() {
    dbManager.saveData()
}

This code might work fine, but if multiple tasks try to save data simultaneously, you could encounter concurrency issues. The solution in Swift 6 would be to encapsulate the dependency within an actor to ensure operations are safe:

swift
actor DatabaseManager {
    func saveData() {
        // Save data safely
    }
}

In projects that use dependency injection, ensure you review all injected classes that may be accessed by multiple threads.

Unit and Concurrent Tests

Another important aspect is adapting unit tests to work with concurrency. In legacy projects, many tests likely run sequentially, without properly handling asynchronous operations. In Swift 6, any test that verifies concurrent or asynchronous operations must be updated to use async/await.

An example of a concurrent test that would need to be refactored:

swift
func testCounterIncrement() {
    var counter = 0

    DispatchQueue.global().async {
        counter += 1
    }

    XCTAssertEqual(counter, 1)
}

This test is not safe under concurrency. When migrating it to Swift 6, we need to adopt async/await to ensure the test code is safe:

swift
func testCounterIncrement() async {
    let counter = Counter()
    await counter.increment()

    XCTAssertEqual(await counter.value, 1)
}

This small change not only ensures the test is safe but also simplifies the asynchronous workflow, making tests more predictable and reliable.

Combine and async/await

For those already working with Combine in legacy projects, one of the significant advantages of Swift 6 is the seamless interoperability between async/await and Combine. This means you can continue using Combine, but now you have the option to integrate async/await where Combine might have been more complex to handle.

  Combine vs RxSwift: Introduction to Combine & differences

For example, if you have a Combine pipeline for handling data downloads, you could refactor it using async/await to simplify some operations:

Before, with Combine:

swift
let publisher = URLSession.shared.dataTaskPublisher(for: url)
    .map { $0.data }
    .sink(receiveCompletion: { _ in }, receiveValue: { data in
        // Process data
    })

Now, with async/await:

swift
func fetchData() async throws -> Data {
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

Although Combine remains a powerful tool, async/await offers a more direct and readable alternative in many cases, especially when handling asynchronous operations in legacy code.

Migrating Third-Party SDKs

One of the big challenges in migrating a legacy project to Swift 6 with Strict Concurrency is how to handle third-party SDKs. Often, these SDKs were designed with older concurrency methods or simply do not contemplate the new rules imposed by Swift 6. So, what do we do when the SDK we use isn’t ready for this change?

1. Check if the SDK has been updated

The first thing we need to do is check if the SDK provider has released a new version compatible with Swift 6 and Strict Concurrency. Many major companies that develop SDKs are aware of the changes Swift brings, so it’s likely they have updated their libraries.

If the SDK is already compatible with async/await and has been adapted to work safely with concurrency, migration will be much easier.

swift
// SDK updated with async/await
func performAction() async throws {
    let result = try await sdkInstance.someAsyncFunction()
    return result
}

2. Encapsulate SDKs that do not support Strict Concurrency

What if the SDK you are using is not ready for Swift 6? This is where things get tricky. If an SDK still depends on DispatchQueue or manual concurrency methods, you’ll have to do some extra work to ensure the SDK’s use is safe in your app.

The simplest solution is to encapsulate SDK calls within an actor. This ensures that any access to the SDK is controlled, even if the SDK itself is not handling concurrency properly:

swift
actor SDKWrapper {
    let sdkInstance = SomeThirdPartySDK()

    func performAction() async {
        sdkInstance.action()
    }
}

This way, you can continue using the SDK without worrying about potential concurrency errors. However, this solution is only a temporary patch until the SDK is officially updated by its developers.

3. Migrate to another SDK (if necessary)

In some cases, the SDK provider you’re using may take a long time to update their library or, worse, they may no longer provide active support. In these cases, you need to evaluate whether it is possible to migrate to another SDK that is already prepared to work with Swift 6 and Strict Concurrency.

Migrating to a new SDK can be a headache, but sometimes it’s the best option to avoid long-term issues, especially if the current SDK is outdated or full of dependencies that also need updating.

4. Communication with the SDK provider

Another important step is to contact the SDK provider if you don’t see a clear migration plan. Often, companies have update roadmaps or can provide information on when a version compatible with Swift 6 will be available.

Additionally, staying in touch with the SDK developers also allows you to give feedback or report issues you may encounter while trying to adapt your project to Strict Concurrency.

5. Testing and Monitoring

Regardless of whether the SDK is compatible or you’ve decided to encapsulate it in an actor, thorough testing is key. Concurrency introduces additional risks that may not have been evident in previous versions of Swift, so you’ll need to closely monitor how the SDK interacts with your application post-migration.

For example, if the SDK handles network or database tasks asynchronously, some concurrency errors may only manifest under certain load conditions. The use of unit testing and concurrent stress testing is essential at this stage.

Full Example of SDK Encapsulation

Let’s assume we are using an authentication SDK that is not adapted to Swift 6. We can encapsulate it in an actor to ensure its use is safe in our environment:

swift
actor AuthSDKWrapper {
    let authSDK = LegacyAuthSDK()

    func login(username: String, password: String) async throws -> Bool {
        return try await withCheckedThrowingContinuation { continuation in
            authSDK.login(username: username, password: password) { success, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: success)
                }
            }
        }
    }
}

In this example, we use withCheckedThrowingContinuation to convert an API with closures (common in many legacy SDKs) to an API based on async/await, ensuring the entire flow is safe and predictable.

  Key Android and iOS Accessibility Features

Advice for Gradual Migration to Swift 6 with Strict Concurrency

Migrating to Swift 6 with Strict Concurrency doesn’t have to be an overwhelming process. Adopting a gradual approach not only reduces risks but allows your team to learn and adapt to the new rules over time. Here are some key points for a smooth transition:

1. Address Critical Modules First

Prioritize the modules that handle critical asynchronous tasks, such as network operations or databases. These tend to be the most sensitive to concurrency errors and where async/await can bring the most immediate benefit. Migrating these essential components first will help stabilize the system from the outset.

2. Gradually Refactor Shared Dependencies

Classes or services shared across multiple parts of the system, such as network or database managers, are key areas to review. Encapsulating these dependencies in actors or adapting their flow to async/await will help reduce concurrency risks without altering the expected behavior.

3. Maintain Compatibility with Temporary Adapters

If you’re working with SDKs or external libraries that haven’t yet been updated to Swift 6, you can encapsulate their operations using adapters. This will allow you to continue migrating without being blocked while waiting for third-party updates.

4. Don’t Overlook Tests

As you migrate your code, make sure your unit tests are updated to comply with the new concurrency rules. Concurrent tests should evolve alongside your code to avoid introducing undetected errors during migration.

5. Train Your Development Team

Finally, remember that migrating to Swift 6 isn’t just a technical issue; it also involves a shift in how we work. Make sure your team learns the new tools and paradigms by incorporating collaborative reviews and training sessions around async/await and actors.

Recommendations

Migrating to Swift 6 isn’t just a technical improvement; it’s a necessity that’s looming for apps that want to remain compliant with App Store policies. Starting in April 2025, all new apps submitted will need to be built with Xcode 16 and the iOS 18 SDK. While this isn’t strictly mandatory, it is strongly recommended, making it an almost inevitable step for any development team.

A gradual approach is key to preventing migration from becoming an unnecessary burden. If you start now, progressively adapting the most critical parts of your application, not only will you be prepared for future requirements, but you’ll also reduce the risk of encountering last-minute issues.

This is a great opportunity to review legacy code, ensure it complies with the new concurrency standards, and improve the overall stability of the application. And don’t forget that team training is just as crucial: the new concurrency paradigms in Swift 6 require a shift in how we think and work with asynchronous code.

In summary, while the migration may seem like a short-term challenge, it’s a necessary investment that will ensure you’re aligned with future standards while building a safer and more efficient foundation for your applications.

References

Author

  • Aitor Pagan 1

    Aitor is an enthusiastic iOS Engineer eager to contribute to team success through hard work, attention to detail and excellent organizational skills. Clear understanding of iOS Platforms and Clean Code Principles and training in Team Management. Motivated to learn, grow and excel in Software Engineering.

    View all posts

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Subscribe To Our Newsletter

Get updates from our latest tech findings

Have a challenging project?

We Can Work On It Together

apiumhub software development projects barcelona
Secured By miniOrange