Migrating Swift API Calls to Async Await

Beginning in iOS 15 and macOS 13, Swift developers can begin using the Swift async/await syntax to to suspend and resume processing while waiting for long-running work to complete. This post walks through the process to upgrade a set of nested API calls from the familiar closure syntax to the new and improved Swift async/await syntax.

The Pyramid of Doom

First, why is this important?

While reading through the Swift Evolution docs on async/await, I ran across the term The Pyramid of Doom, and it hit very close to home! Like may iOS developers, every app I build has network dependencies that require async calls. Managing closures and dispatching threads is an every day part of the job. And for all their utility, closures can be painful and tedious!

This pseudocode snippet illustrates the problem with closures encountered in any moderately complex app that uses asynchronous API calls (or other background thread async data processing).

    func getSomeData(completion: (response?, error?) -> Void) {
        callAPI("/v1/api1") { result in
    		if result.statusCode == 200 {
                callAPI("/v1/api2") { result in 
                    if result.statusCode == 200 {
                        callAPI("/v1/api3") { result in 
                           if let result.statusCode == 200 {
                               completion(result, nil)
                           } else {
                               completion(makeError(), nil)
                           }
                        } else {
                           completion(makeError(), nil)
                        }
                    } else {
                        completion(makeError(), nil)
                    }
                }
            } else {
                completion(makeError(), nil)
            }
        }
    }
    
    DispatchQueue.global().async {
       getSomeData { response, error in
           DispatchQueue.main.async {
              if let error = error {
                 print(error.localizedDescription)
              } else if response = response {
                 print(response.finalAnswer
              }
           }
       }
    }

Hopefully not every API calling process you implement has this structure—not all of my development looks like this (thank goodness!). But does this level of asynchronous nesting happen sometimes? Absolutely! We can’t always control how a backend is implemented, and it’s not uncommon to chain asynchronous operations like this.

Sadly, when error handling and JSON deserialization is added to the process, even much less nesting can result in a dreaded pyramid of doom.

The problems, as most are familiar, are these:

  1. This code is difficult to read and has many points of failure.
  2. Any code path where the completion(..) call is forgotten results in as scenario where getSomeData never returns control flow to the calling routine, and the calling routine will appear (to the user) to have stopped responding. The caller is stuck in an endless wait, and may even need to kill the app to recover from the bug.
  3. We have to manually decide how to schedule work on threads, and (usually) remember to dispatch back to the UI thread after completion handlers return.
  4. Can the compiler flag any mistakes in the completion handler logic? Nope, it can’t. It’s on the developer to check every possible code path for missed callbacks before shipping the code.

But now there’s finally a better solution—async/await!

The Async/Await Solution to the Pyramid of Doom

The solution to the Pyramid of Doom is async/await. This isn’t a new idea, and in fact is a proven feature that has been implemented in other languages before. I used the C# implementation of async/await for years, and really miss it when working in Swift.

The main feature of async/await is that it makes async code seem as though it was synchronous, which makes it easier to reason about and maintain.  Compare these two snippets:

Async with closure:

    callAPI("/v1/api1") { result in
       if result.statusCode == 200 {
          print("Success"
       }
    }

**Async with await: **

    let result = try await callAPI("/v1/api1")
    
    if result.status == 200 {
       print("Success")
    }

How else does async/await differ from closures?

While the main benefit for a developer in adopting async/await syntax is easier to develop, read, debug and maintain code, there are architectural benefits in async/await that aren’t as obvious.

Code within the getSomeData() function in the Pyramid of Doom version runs on the thread that called the function. Let’s say we just called getSomeData() from the UI thread (without the DispatchQueue). The UI thread would be blocked until one of the completion closures was called. Ouch. Of course we don’t want that, so we typically spawn this kind of work on a background thread as we did with DispatchQueue. Still, we’re instructing the OS to use more threads to solve our concurrency problem. It wouldn’t be uncommon to have getSomeData() spawned on one thread, and then each of the callApi calls dispatched to additional threads. Any OS has a limited number of threads available—they’re a precious resource.

You can learn more about the operation and internal mechanics of async await by viewing this WWDC21 video Meet async/await in Swift.

Thread management under Suspend/Resume

The Swift async/await architecture removes the need to explicitly dispatch work to background threads. When await is called, the system can suspend the work on the original thread that used the await keyword, and has discretion how to schedule the called function in the thread pool. When that awaited function returns, the runtime system will resume the original program flow from the line after the await call on the thread it was on before.

I wrote can suspend because…it may not! The runtime system knows the big picture of thread management, and it will decide how it wants to schedule our work on its threads, and will ensure that we’re not blocking the thread we called the awaited function from. If the runtime decides it can run the function on the same thread—it can do that. If it wants to dispatch the function we await to another thread—it can do that as well. All we need to know is that when we use await, we’ won’t block the thread we’re on, and our code will eventually resume on the same thread after the awaited function returns. Awesome.

A Concrete Example

The getData function is just pseudocode, but let’s look at a real-world example! This is a simple weather app that makes API calls. I first implemented it in the traditional closure model, and then upgraded it to async/await—to illustrate the changes needed to make the migration. While simple and easy to read, the same migration process would apply to many iOS/macOS apps (or, as in this example, command line applications).

Weather App Scope

The app in this section illustrates a common scenario, where we need to call an endpoint in one Web API, deserialize the response, and use data in the response to make a call to a second API, then return some final result. In this example, the two APIs are on different platforms, so it would not be possible to combine the API calls in the backend systems.

The flow of this macOS console app is the following:

  1. User runs the app from the command line, passing in the name of a city
  2. App fetches a list of cities from an Azure-based web API.
  3. App searches the city list to find the latitude and longitude of the city
  4. App then queries the Dark Sky weather forecast API to get the current weather conditions for the city.
  5. App prints a summary of weather conditions to stdout.

The code snippets below include the pieces of code most relevant to the asynchronous calls. At the end of the article is the entire console application with both versions of the async calls, and a link for the source code in GitHub.

Pyramid of Doom Version

The first implementation of this app is using closures.

func fetchWeatherWithClosures(cityName: String, completion: 
                              @escaping (Currently?) -> Void) {
  let cityTask = URLSession.shared.dataTask(with: 
        URLRequest(url: cityUrl)) { data, response, error in
    if let data = data {
      if let cities = try? decoder.decode([City].self, 
                                                from: data),
      let city = cities.first(where: {$0.name == cityName}) {
            let darkSkyUrl = URL(string: 
              "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
                    
            let weatherTask = URLSession.shared.dataTask(
                with: URLRequest(url: darkSkyUrl)) { data, 
                                          response, error in
                            if let data = data {
                                if let weather = 
                            try? decoder.decode(Weather.self, 
                                    from: data) {
                                    completion(weather.currently)
                                } else {
                                    completion(nil)
                                }
                            } else {
                                completion(nil)
                            }
                    }
                    weatherTask.resume()
            } else {
                completion(nil)
            }
        } else {
            completion(nil)
        }
    }
    cityTask.resume()
}

As with most nested async apps, this one has enough depth to creates many closure calling points, and makes it hard to read. Note that it only took two API calls (with two JSON deserialization operations) to generate significant nesting complexity!

The async/await version

Contrast the above with the below async/await version of code that does the same work.

func fetchWeatherWithAwait(cityName: String) 
                            async throws -> Currently? {

    // Fetch City list from Azure API
    let (data, _) = try await URLSession.shared.data(
                              for: URLRequest(url: cityUrl))

    if let cities = try? decoder.decode([City].self, from: data),
        let city = cities.first(where: {$0.name == cityName}) {

        // Fetch Weather from Dark Sky API
        let darkSkyUrl = URL(string: "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!

        let (data, _) = try await URLSession.shared.data(
                          for: URLRequest(url: darkSkyUrl))

        return (try? decoder.decode(Weather.self, 
                              from: data))?.currently
    }

    return nil
}

The improvements in the await version are immediately obvious:

  1. The readability of the await logic  in the second version is superior to the nested closure version
  2. There’s one entry point to the await version's routine, one successful exit point, and one error exit point.
  3. There’s no manual thread management code needed in the calling point (see complete example below).

This code reads as if it were a synchronous version, doesn’t it? In fact, that’s the real benefit to async/await—that it makes asynchronous code read as if it was synchronous code. The suspend/resume is handled behind the scenes by the compiler.

In reality, compiler generates code that dispatches blocks to threads, makes callbacks and glues the various async blocks back together. Threads are still being managed and synchronized, even if we don't write that code ourselves.

Rather than the app developer needing to think about how to manage all those details, the compiler is taking on the heavy lifting. Since the runtime system has a better big picture view of how threads in the pool should be used, the overall solution is more efficient. Win/win!

Summary

Async/await is a big win for app developers, and I’d argue is the most important new feature in Xcode 13 for iOS/macOS developers. Await makes it easier than ever for app developers to create performant, responsive code—that’s easier to debug and maintain.

But while closures aren’t being deprecated and will still be used for a few years to support iOS 14/macOS 11 and previous target OS levels, we can expect async/await to become the standard approach for scheduling background asynchronous work within iOS and macOS applications.

Get the Code

Here’s the full console application source code:

import Foundation

// Current weather - subset of properties returned from Dark Sky API
struct Currently : Decodable {
    let summary: String
    let temperature: Double
    let humidity: Double
    
    func sentence(_ cityName: String) -> String {
        return "Current weather in \(cityName): \(summary), Temp=\(round(temperature))º, Humidity=\(round(humidity * 100.0))"
    }
}

// Weather object - top level JSON object from Dark Sky (most properties ignored)
struct Weather : Decodable {
    let currently: Currently
}

// City objects returned from Azure API
struct City: Decodable {
    let name: String
    let airport: String
    let lat: Double
    let lon: Double
}

// DispatchGroup used to avoid the console app exiting before async work has completed
let dispatchGroup = DispatchGroup()

let decoder = JSONDecoder()
let cityUrl = URL(string: "https://robkerrblog.blob.core.windows.net/data/cities.json")!

// Place your API key here
let darkSkyKey = "*********************"
let darkSkyUrl = "https://api.darksky.net/forecast"

// This routine uses API calls and closures
func fetchWeatherWithClosures(cityName: String, completion: @escaping (Currently?) -> Void) {
    let cityTask = URLSession.shared.dataTask(with: URLRequest(url: cityUrl)) { data, response, error in
        if let data = data {
            if let cities = try? decoder.decode([City].self, from: data),
                  let city = cities.first(where: {$0.name == cityName}) {
                    let darkSkyUrl = URL(string: "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
                    
                    let weatherTask = URLSession.shared.dataTask(with: URLRequest(url: darkSkyUrl)) { data, response, error in
                            if let data = data {
                                if let weather = try? decoder.decode(Weather.self, from: data) {
                                    completion(weather.currently)
                                } else {
                                    completion(nil)
                                }
                            } else {
                                completion(nil)
                            }
                    }
                    weatherTask.resume()
            } else {
                completion(nil)
            }
        } else {
            completion(nil)
        }
    }
    cityTask.resume()
}

// The version that uses async/await
func fetchWeatherWithAwait(cityName: String) async throws -> Currently? {

    // Fetch City list from Azure API
    let (data, _) = try await URLSession.shared.data(for: URLRequest(url: cityUrl))

    if let cities = try? decoder.decode([City].self, from: data),
        let city = cities.first(where: {$0.name == cityName}) {

        // Fetch Weather from Dark Sky API
        let darkSkyUrl = URL(string: "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
        let (data, _) = try await URLSession.shared.data(for: URLRequest(url: darkSkyUrl))

        return (try? decoder.decode(Weather.self, from: data))?.currently
    }

    return nil
}
    
// Mainline of app. Supported city name on command line (Ann Arbor, Houghton, Seattle, Paris)
// This main line will run both versions (Closure and Async/Await). Each outputs the result to stdout
if CommandLine.arguments.count < 2 {
    print("Usage: NestedAPIAwaitRefactor \"<city name>\"")
} else {
    let cityName = CommandLine.arguments[1]
    
    //======= This is the closure version of the application (before refactor)  =======
    dispatchGroup.enter()
    fetchWeatherWithClosures(cityName: cityName) { currentWeather in
        if let weather = currentWeather {
            print("=== closure " + weather.sentence(cityName))
        } else {
            print("Error fetching weather!")
        }
        dispatchGroup.leave()
    }
    
    //======= This is the async/await version of the application (after refactor)  =======
    dispatchGroup.enter()
    Task {
        if let weather = try await fetchWeatherWithAwait(cityName: cityName) {
            print("=== async/await " + weather.sentence(cityName))
        } else {
            print("Error fetching weather!")
        }
        dispatchGroup.leave()
    }
        
    dispatchGroup.notify(queue: DispatchQueue.main) {
        // This block called when both versions of the app have completed
        exit(EXIT_SUCCESS)
    }
    dispatchMain()
}

You can find the full source code for the Weather app, including the Pyramid of Doom and async/await alternative functions on my GitHub blog repository here.