Xcode Project Navigator showing the AsyncAwait iOS sample project structure, by Wolfpack Digital

Migrating your project to the new Swift concurrency using async/await

blog post publisher

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.

Xcode Project Navigator showing the AsyncAwait sample project with Scenes, Models, and Network folders

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.

Original APIService struct using a completion handler with a URLSession data task

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.

APIProtocol definition with an asURLRequest method that builds a URLRequest

3. NewsAPI

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

NewsAPI enum defining the getNews endpoint method, path, and query items

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.

NewsViewModel calling APIService with a completion handler and a Result switch

Migrating to async/await

1. Updating the APIService

Refactored async throws APIService using URLSession async data and throwing errors

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:

Refactored NewsViewModel using a Task, do-catch, and MainActor.run to update the UI

So what changed?

    • We create a Task, a unit of asynchronous work. Without it, we get this error:

Xcode error: async call in a function that does not support concurrency

    • 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:

NewsViewModel making two sequential await API calls inside one Task

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.

NewsViewModel using withThrowingTaskGroup to run grouped read-time tasks concurrently

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

Apple documentation for the withThrowingTaskGroup function

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

APIService bridging Alamofire to async/await using withCheckedThrowingContinuation

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."

Frequently asked questions

Async/await is part of Swift's structured concurrency model, introduced in Swift 5.5. It lets you write asynchronous code that reads like synchronous code, replacing nested completion handlers with async functions and await statements.
Awaited network calls run off the main thread, so wrap UI updates in await MainActor.run (or mark the code with @MainActor). This switches to the main thread before you touch published properties or UI state.
Use withThrowingTaskGroup(of:) to add a child task per item and await them together with group.waitForAll(). For a few independent calls, you can also use async let bindings.
Yes. Alamofire has shipped native async/await support since version 5.5, so you can await its response tasks directly. For any SDK that still uses completion handlers, wrap the call in withCheckedThrowingContinuation to bridge it into async/await.