Refactor A Callback Based API To Use Async/Await

Recently I’ve refactored a few callback based network requests to use Swift’s new Async / Await syntax. Let’s take a look at how we can take a conventional callback based API method and improve its readability and simplicity using the async pattern. Note that in this example I have access to & can change the network calling code myself. I’ll be writing a follow up post with an example of how to convert an inaccessible closure based API (e.g. a framework API) to use async await.

The code below is an example of some typical closure / callback based API logic. This example is taken from my demo project that retrieves people from the Star Wars API. Notice the multiple levels of indentation, multiple completion failure call sites, lengthy amount of code, and not least, the final resume() call that triggers the data task (I’ve forgotten about that resume call more times than I’d like to admit).


func loadPeople(completion: @escaping (PersonAPIResult) -> ()) {
    let request = URLRequest(url: peopleURL)
    URLSession.shared.dataTask(with: request) { data, _, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        guard let data = data else {
            completion(.failure("No data was returned"))
            return
        }
        
        let personResponse: PersonResponse
        
        do {
            personResponse = try JSONDecoder().decode(PersonResponse.self, from: data)
        } catch {
            completion(.failure(error))
            return
        }
        
        completion(.success(personResponse.results))
    }
    .resume()
}

If you think it should be easier to make a simple network request, you’re right. These 25 lines or so can be condensed down to about 5 lines if we use the new async await syntax:


func loadPeopleAsync() async throws -> [Person] {
    let request = URLRequest(url: peopleURL)
    let (data, _) = try await URLSession.shared.data(for: request)
    let response = try JSONDecoder().decode(PersonResponse.self, from: data)
    
    return response.results
}

How slick is that? I just added async to the enclosing method and then used the new async data() method on URLSession to perform the network request. The URLRequest didn’t change, and we still get data back from the system networking API (notice the underscore that represents the networking response, which I’m not using in either example above). Note how I also added throws to the enclosing method to pass errors to the caller, greatly simplifying our error handling code in the method.

Here’s a comparison of the call site for both callback based and async based API methods.

Callback based:


StarWarsAPI().loadPeople { result in
    switch result {
    case .success(let personArray):
        print(personArray)
        // Do something with the personArray
    case .failure(let error):
        // Show the user an error message
    }
}

Async based:


let personArray = try await StarWarsAPI().loadPeopleAsync()
print(personArray)
// Do something with the personArray

The async code is much easier to read and understand, plus it’s significantly shorter in both the API implementation and call site. It takes a bit of practice to get the hang of async / await quirks and syntax, but life is much better when you transition away from old callback style code. 

Final note - in the async based call site example above, the enclosing method will also need to be marked with async throws or there will be compiler errors. If you can’t mark the enclosing method with async throws, then you could use a Task to enter an async scope, and use a do / catch block to handle the error. E.g:


Task {
    do {
        let personArray = try await StarWarsAPI().loadPeopleAsync()
        print(personArray)
        // Do something with the personArray
    } catch {
        print(error)
        // Show the user an error message
    }
}

The code is starting to get a little bit long & now has multiple levels of indentation, but the real logic is still concise and readable inside the do block.

One quirk about Task, you can also perform try inside the block, but the error will be completely unhandled:


Task {
    let personArray = try await StarWarsAPI().loadPeopleAsync()
    print(personArray)
    // Do something with the personArray
}

If you’d like to take a look at my demo project, here’s a link to it on GitHub. See you next time!