
Migrating your project to the new Swift concurrency using async/await
Bianca
iOS Developer
Reading time: 10 min
Published: Jul 10, 2023
Key takeaways
- Async/await, introduced in Swift 5.5, is now a mature, standard part of Swift concurrency.
- Migrating removes nested completion handlers and produces cleaner, shorter, safer code.
- Mark functions async throws and call them with try await; use MainActor.run for UI updates.
- Use withThrowingTaskGroup to run and await multiple API calls concurrently.
- Alamofire now has native async support; use withCheckedThrowingContinuation to bridge any SDK that still uses completion handlers.
Async/await in Swift concurrency
Async/await is part of Swift's structured concurrency model, introduced in Swift 5.5 and now a mature, standard part of the language. Concurrency means letting multiple pieces of code run at the same time. With async functions and await statements, you can define asynchronous sequences in a clean, readable way.
Why migrate your iOS project to async/await?
As an iOS developer, you might ask why you should adopt async/await and update an existing project. Using it only on new projects is easier, true. But refactoring a current one gives you much cleaner, better-structured code.
Here are the benefits I found after migrating to async/await:
- Fewer completion handlers. Repeating @escaping (Result<T, APIError>) -> Void gets frustrating. Async/await removes nested handlers and makes code more readable.
- Cleaner error handling. Async/await pairs beautifully with do-try-catch blocks.
- Less risk of hanging completions. You must return a value or throw an error, so you can't forget to call a completion.
- Synchronous-looking code. Asynchronous code reads like synchronous code, so it is easier to reason about.
- Less code overall. Files get shorter and clearer.
Let's see how to migrate the most common iOS scenarios. We'll start with a traditional async project, move through the layers of a well-structured app, then handle multiple API calls and group them. Finally, since many projects use Alamofire on the networking side, we'll cover that too.
Example project: a News app
Our example is a News app that fetches and displays articles from a REST API. The project has a view, a view model, a data model, and a simple networking layer.

Components
Let's look at the networking components and the view model.
1. APIService
This struct has a static generic request method. It expects a route conforming to APIProtocol plus a completion handler. It runs a basic URL request with a data task, decodes the data, then passes the result or error to the completion handler.

2. APIProtocol
This protocol is the interface for API routes. It defines a method, path, optional body parameters, and query items. Its asURLRequest() method builds a basic URLRequest.

3. NewsAPI
This enum holds all news-related endpoints. For simplicity, we'll focus on the getNews case.

4. NewsViewModel
The view model holds a news array and fetches data through the APIService request method. In a larger project, a repository or data source layer would handle this.

Migrating to async/await
1. Updating the APIService

Here is what changed in this method:
- The signature is now marked async throws and returns a Decodable type.
- Because the method is async, we use URLSession's async data(for:). It returns a tuple with the Data object and the URLResponse.
- The decoded data is returned directly, with no completion handler.
- Error handling is simpler with throw. You either return a value or throw an error.
Refactoring this file dropped the line count from 38 to 33, removing about 13% of the code. That may seem small, but this is a very simple example. Real projects hold far more complex code.
2. Updating the view model
Now that the APIService is refactored, here is how the view model looks:

So what changed?
- We create a Task, a unit of asynchronous work. Without it, we get this error:

- We use a do-try-catch structure. Not everyone loves it, but it adds clarity and is common in many languages.
- We call the request method with try await. These keywords make clear that execution continues only once a response arrives.
- Because the awaited call runs on a background thread, we update the UI on the main thread. We use await MainActor.run to switch to the main thread before setting the published news array.
But what if you need multiple API calls? To show this, let's add a call that fetches a "People of the Day" section. The view model then looks like this:

Since the second call runs right after the first, a failure in the first one is caught and no data displays. To avoid that, use two separate methods, such as loadNews() and loadPeopleOfTheDay(). That way, if one fails and the other succeeds, the good data still displays and the error is handled.
Grouped API calls
Async/await also lets you group tasks and wait for all of them to finish before moving on.
To show this, let's add a getReadTime call that fetches the reading time of each article in seconds. Assume we must make a separate call per article.

To group tasks, we use await withThrowingTaskGroup(of:). This one is a bit more complex, so let's see what the documentation says:

Looking at our code:
- A group returns a result. Here the child tasks return nothing; they just update the news items. So the result is (), hence the _ = try await withThrowingTaskGroup(of: ...).
- For each news item, we create a loadReadTime task and add it to the group.
- try await group.waitForAll() waits for all tasks to finish before returning.
Fairly easy. But what if you use a third-party networking SDK without native async/await support?
Migrating a networking layer that uses Alamofire

These days, Alamofire ships native async/await support (from version 5.5 onward), so you can await its response tasks directly. But the same pattern shown here is invaluable for any SDK that still uses completion handlers. The bridge is withCheckedThrowingContinuation:
- Wrap the completion-handler API in await withCheckedThrowingContinuation. It bridges Swift's async/await model with older asynchronous APIs.
- On a success result, call continuation.resume(returning:).
- On a failure result, call continuation.resume(throwing:).
Wrapping up
Code cleanliness and readability matter as much as keeping your apps up to date with the latest tools. Adopting async/await pairs nicely with refactoring older code and giving legacy iOS apps a fresh start. Need a hand modernizing your app? Our iOS development team can help, and you can read more of our mobile development articles.
And to end with a lightly altered proverb: "Good things come to those who await."



