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

 1    func getSomeData(completion: (response?, error?) -> Void) {
 2        callAPI("/v1/api1") { result in
 3    		if result.statusCode == 200 {
 4                callAPI("/v1/api2") { result in 
 5                    if result.statusCode == 200 {
 6                        callAPI("/v1/api3") { result in 
 7                           if let result.statusCode == 200 {
 8                               completion(result, nil)
 9                           } else {
10                               completion(makeError(), nil)
11                           }
12                        } else {
13                           completion(makeError(), nil)
14                        }
15                    } else {
16                        completion(makeError(), nil)
17                    }
18                }
19            } else {
20                completion(makeError(), nil)
21            }
22        }
23    }
25    DispatchQueue.global().async {
26       getSomeData { response, error in
27           DispatchQueue.main.async {
28              if let error = error {
29                 print(error.localizedDescription)
30              } else if response = response {
31                 print(response.finalAnswer
32              }
33           }
34       }
35    }

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:

1    callAPI("/v1/api1") { result in
2       if result.statusCode == 200 {
3          print("Success"
4       }
5    }

**Async with await: **

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

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.

 1func fetchWeatherWithClosures(cityName: String, completion: 
 2                              @escaping (Currently?) -> Void) {
 3  let cityTask = URLSession.shared.dataTask(with: 
 4        URLRequest(url: cityUrl)) { data, response, error in
 5    if let data = data {
 6      if let cities = try? decoder.decode([City].self, 
 7                                                from: data),
 8      let city = cities.first(where: {$0.name == cityName}) {
 9            let darkSkyUrl = URL(string: 
10              "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
12            let weatherTask = URLSession.shared.dataTask(
13                with: URLRequest(url: darkSkyUrl)) { data, 
14                                          response, error in
15                            if let data = data {
16                                if let weather = 
17                            try? decoder.decode(Weather.self, 
18                                    from: data) {
19                                    completion(weather.currently)
20                                } else {
21                                    completion(nil)
22                                }
23                            } else {
24                                completion(nil)
25                            }
26                    }
27                    weatherTask.resume()
28            } else {
29                completion(nil)
30            }
31        } else {
32            completion(nil)
33        }
34    }
35    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.

 1func fetchWeatherWithAwait(cityName: String) 
 2                            async throws -> Currently? {
 4    // Fetch City list from Azure API
 5    let (data, _) = try await URLSession.shared.data(
 6                              for: URLRequest(url: cityUrl))
 8    if let cities = try? decoder.decode([City].self, from: data),
 9        let city = cities.first(where: {$0.name == cityName}) {
11        // Fetch Weather from Dark Sky API
12        let darkSkyUrl = URL(string: "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
14        let (data, _) = try await URLSession.shared.data(
15                          for: URLRequest(url: darkSkyUrl))
17        return (try? decoder.decode(Weather.self, 
18                              from: data))?.currently
19    }
21    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!


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:

  1import Foundation
  3// Current weather - subset of properties returned from Dark Sky API
  4struct Currently : Decodable {
  5    let summary: String
  6    let temperature: Double
  7    let humidity: Double
  9    func sentence(_ cityName: String) -> String {
 10        return "Current weather in \(cityName): \(summary), Temp=\(round(temperature))º, Humidity=\(round(humidity * 100.0))"
 11    }
 14// Weather object - top level JSON object from Dark Sky (most properties ignored)
 15struct Weather : Decodable {
 16    let currently: Currently
 19// City objects returned from Azure API
 20struct City: Decodable {
 21    let name: String
 22    let airport: String
 23    let lat: Double
 24    let lon: Double
 27// DispatchGroup used to avoid the console app exiting before async work has completed
 28let dispatchGroup = DispatchGroup()
 30let decoder = JSONDecoder()
 31let cityUrl = URL(string: "https://robkerrblog.blob.core.windows.net/data/cities.json")!
 33// Place your API key here
 34let darkSkyKey = "*********************"
 35let darkSkyUrl = "https://api.darksky.net/forecast"
 37// This routine uses API calls and closures
 38func fetchWeatherWithClosures(cityName: String, completion: @escaping (Currently?) -> Void) {
 39    let cityTask = URLSession.shared.dataTask(with: URLRequest(url: cityUrl)) { data, response, error in
 40        if let data = data {
 41            if let cities = try? decoder.decode([City].self, from: data),
 42                  let city = cities.first(where: {$0.name == cityName}) {
 43                    let darkSkyUrl = URL(string: "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
 45                    let weatherTask = URLSession.shared.dataTask(with: URLRequest(url: darkSkyUrl)) { data, response, error in
 46                            if let data = data {
 47                                if let weather = try? decoder.decode(Weather.self, from: data) {
 48                                    completion(weather.currently)
 49                                } else {
 50                                    completion(nil)
 51                                }
 52                            } else {
 53                                completion(nil)
 54                            }
 55                    }
 56                    weatherTask.resume()
 57            } else {
 58                completion(nil)
 59            }
 60        } else {
 61            completion(nil)
 62        }
 63    }
 64    cityTask.resume()
 67// The version that uses async/await
 68func fetchWeatherWithAwait(cityName: String) async throws -> Currently? {
 70    // Fetch City list from Azure API
 71    let (data, _) = try await URLSession.shared.data(for: URLRequest(url: cityUrl))
 73    if let cities = try? decoder.decode([City].self, from: data),
 74        let city = cities.first(where: {$0.name == cityName}) {
 76        // Fetch Weather from Dark Sky API
 77        let darkSkyUrl = URL(string: "\(darkSkyUrl)/\(darkSkyKey)/\(city.lat),\(city.lon)")!
 78        let (data, _) = try await URLSession.shared.data(for: URLRequest(url: darkSkyUrl))
 80        return (try? decoder.decode(Weather.self, from: data))?.currently
 81    }
 83    return nil
 86// Mainline of app. Supported city name on command line (Ann Arbor, Houghton, Seattle, Paris)
 87// This main line will run both versions (Closure and Async/Await). Each outputs the result to stdout
 88if CommandLine.arguments.count < 2 {
 89    print("Usage: NestedAPIAwaitRefactor \"<city name>\"")
 90} else {
 91    let cityName = CommandLine.arguments[1]
 93    //======= This is the closure version of the application (before refactor)  =======
 94    dispatchGroup.enter()
 95    fetchWeatherWithClosures(cityName: cityName) { currentWeather in
 96        if let weather = currentWeather {
 97            print("=== closure " + weather.sentence(cityName))
 98        } else {
 99            print("Error fetching weather!")
100        }
101        dispatchGroup.leave()
102    }
104    //======= This is the async/await version of the application (after refactor)  =======
105    dispatchGroup.enter()
106    Task {
107        if let weather = try await fetchWeatherWithAwait(cityName: cityName) {
108            print("=== async/await " + weather.sentence(cityName))
109        } else {
110            print("Error fetching weather!")
111        }
112        dispatchGroup.leave()
113    }
115    dispatchGroup.notify(queue: DispatchQueue.main) {
116        // This block called when both versions of the app have completed
117        exit(EXIT_SUCCESS)
118    }
119    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.