Traditionally, when we want to make a network request, we must use the closure-based URLSession
APIs to perform the request asynchronously so that our apps can be responsive while waiting for it to complete. With the release of Swift 5.5, this is no longer the case, we now have another alternative which is to use async/await.
In this article, I would like to show you how to make a network request by using the async/await keywords. On top of that, I will also do a quick comparison between async/await and closure-based API so that you can better understand the benefits of using async/await.
This article does require you to have a basic understanding of async/await. Therefore, if you’re unfamiliar with Swift concurrency, I highly encourage you to first read my blog post called “Getting Started with Swift Concurrency“.
With all that being said, let’s get right into it!
Prerequisite
Throughout this article, we will be using the Apple iTunes API to fetch a collection of albums from Taylor Swift. Following is the API’s URL:
https://itunes.apple.com/search?term=taylor+swift&entity=album
This API endpoint will gives us the following JSON response:
For demonstration purposes, we will grab the album’s name and price and show them on a collection view list. Here are the model objects that we need for JSON decoding:
struct ITunesResult: Codable {
let results: [Album]
}
struct Album: Codable, Hashable {
let collectionId: Int
let collectionName: String
let collectionPrice: Double
}
Note that we are conforming the Album
struct to the Hashable
protocol so that we can use it as our collection view diffable data source’s item identifier type.
With all that out of the way, let’s get into the network requests code.
The Traditional Way
Before Swift 5.5, in order to make a network request, we must use the closure-based URLSession
‘s dataTask(with:completionHandler:)
method to trigger a request that runs asynchronously in the background. Once the network request is completed, the completion handler will give us back the result from the network request.
For simplicity’s sake, let’s define an AlbumsFetcher
struct for that:
struct AlbumsFetcher {
enum AlbumsFetcherError: Error {
case invalidURL
case missingData
}
static func fetchAlbums(completion: @escaping (Result<[Album], Error>) -> Void) {
// Create URL
guard let url = URL(string: "https://itunes.apple.com/search?term=taylor+swift&entity=album") else {
completion(.failure(AlbumsFetcherError.invalidURL))
return
}
// Create URL session data task
URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(AlbumsFetcherError.missingData))
return
}
do {
// Parse the JSON data
let iTunesResult = try JSONDecoder().decode(ITunesResult.self, from: data)
completion(.success(iTunesResult.results))
} catch {
completion(.failure(error))
}
}.resume()
}
}
If you have previously worked on code that makes network requests, the above fetchAlbums(completion:)
function should look familiar to you. We first start a data task to make the network request. Once the request is completed, we check for errors and parse the response JSON.
Calling the fetchAlbums(completion:)
function is also pretty straightforward:
AlbumsFetcher.fetchAlbums { [unowned self] result in
switch result {
case .success(let albums):
// Update UI using main thread
DispatchQueue.main.async {
// Update collection view content
updateCollectionViewSnapshot(albums)
}
case .failure(let error):
print("Request failed with error: \(error)")
}
}
One thing to take note of is that the updateCollectionViewSnapshot(_:)
function is a helper function that updates our list based on the albums
array. Therefore, we need to dispatch back to the main thread before calling it.
With the traditional way out of the way, in the next section, let’s look at how we can achieve the same thing using the new async/await keyword.
The Swift Concurrency Way
In order to convert our closure-based fetchAlbums(completion:)
function into the new async/await style, we can take 2 totally different approaches.
The first approach is to use the CheckedContinuation
(introduced in Swift 5.5) to bridge the fetchAlbums(completion:)
function with the asynchronous context, whereas the second approach is to replace the closure-based URLSession
with the async variant of URLSession
.
For now, let’s first focus on the CheckedContinuation
approach.
CheckedContinuation
CheckedContinuation
is a new mechanism in Swift 5.5 that helps developers to bridge between synchronous and asynchronous code. We can create a CheckedContinuation
using the withCheckedThrowingContinuation(function:_:)
or the withCheckedContinuation(function:_:)
method.
In our case, since the fetchAlbums(completion:)
function’s completion handler will return an error, we will use the “throwing” variant of the method to create a continuation. Here’s how:
static func fetchAlbumWithContinuation() async throws -> [Album] {
// Bridge between synchronous and asynchronous code using continuation
let albums: [Album] = try await withCheckedThrowingContinuation({ continuation in
// Async task execute the `fetchAlbums(completion:)` function
fetchAlbums { result in
switch result {
case .success(let albums):
// Resume with fetched albums
continuation.resume(returning: albums)
case .failure(let error):
// Resume with error
continuation.resume(throwing: error)
}
}
})
return albums
}
As you can see, the withCheckedThrowingContinuation(function:_:)
method accepts a closure that takes a continuation parameter. It creates an async task that executes the fetchAlbums(completion:)
function to trigger the network request asynchronously.
In the above code, there are a few important aspects that you should be aware of:
- The
withCheckedThrowingContinuation(function:_:)
method is marked asasync
, therefore we must call it using theawait
keyword. On top of that, since we are using the “throwing” variant of it, we need to use thetry
keyword as well (just like calling a normal function that throws). - We must call a resume method exactly once on every execution path throughout the async task. Resuming from a continuation more than once is undefined behavior. Whereas never resuming will leave the async task in a suspended state indefinitely, we call this continuation leak.
- The return type of
withCheckedThrowingContinuation(function:_:)
method must match with theresume(returning:)
method’s parameter data type, which is[Album]
.
Now, let’s switch our focus to the call site. Assuming that we are calling the fetchAlbumWithContinuation()
function in a view controller, we can call it like this:
// Start an async task
Task {
do {
let albums = try await AlbumsFetcher.fetchAlbumWithContinuation()
// Update collection view content
updateCollectionViewSnapshot(albums)
} catch {
print("Request failed with error: \(error)")
}
}
As usual, we must create an async task so that we can await and execute the fetchAlbumWithContinuation()
function in an asynchronous context. Since we no longer use a completion handler, we can now handle the errors thrown by the function using a do
–catch
statement.
Also, notice that dispatching to the main thread before calling updateCollectionViewSnapshot()
is no longer necessary because we are calling the fetchAlbumWithContinuation()
function in a view controller, which is a MainActor
.
Note:
If you are not familiar with
MainActor
, you can check out my previous article Getting Started with Swift Concurrency.
Async URLSession
In Swift 5.5, aside from releasing the async
and await
keywords, Apple has also updated many of their own SDKs to support both of these keywords, and one of them is URLSession
.
Apple has added a new data(url:)
method to URLSession
which is equivalent to the dataTask(with:completionHandler:)
method we used previously. It is a throwing async method that returns a tuple of Data
and URLResponse
. Here’s how to use it to make a network request:
static func fetchAlbumWithAsyncURLSession() async throws -> [Album] {
guard let url = URL(string: "https://itunes.apple.com/search?term=taylor+swift&entity=album") else {
throw AlbumsFetcherError.invalidURL
}
// Use the async variant of URLSession to fetch data
// Code might suspend here
let (data, _) = try await URLSession.shared.data(from: url)
// Parse the JSON data
let iTunesResult = try JSONDecoder().decode(ITunesResult.self, from: data)
return iTunesResult.results
}
The code above is pretty much self-explanatory, however, do take note that the URLSession.data(from:)
method is an async method, therefore the code might suspend when waiting for it to return. This is also why we need to call it using the await
keyword.
Following is the call site of fetchAlbumWithAsyncURLSession()
, which is basically the same as calling fetchAlbumWithContinuation()
:
// Start an async task
Task {
do {
let albums = try await AlbumsFetcher.fetchAlbumWithAsyncURLSession()
// Update collection view content
updateCollectionViewSnapshot(albums)
} catch {
print("Request failed with error: \(error)")
}
}
At this stage, you might ask: What’s the point of using CheckedContinuation
if the URLSession
‘s APIs already support async/await? Well, you are absolutely right! We should definitely use the async/await variant of any APIs if they are available.
The CheckedContinuation
is mainly for bridging any asynchronous APIs that are yet to support the async/await syntax. If let’s say you are using a third-party networking library (such as Alamofire) that does not support the async/await syntax, then you can use CheckedContinuation
to gradually migrate your existing code to support async/await while waiting for the third-party library to get updated.
Async/await vs Closure
Ever since Apple introduced async/await in WWDC21, I have been asked by several junior developers: why everyone is so hyped for async/await while we can already do the exact same thing by using closures and dispatch queues?
In this section, let’s try to answer this question by quickly go through some of the benefits of using async/await:
- When using closures, we might forget to call the completion handler and there is no way to prevent that from happening. When using async/await, if we didn’t return from the async function, we will get a compilation error.
- It is impossible to use a
do
–catch
statement to handle errors when using closures because closures don’t support that. On the other hand, we can handle errors thrown by an async function using ado
–catch
statement just like handling errors thrown by a normal function. - By using async/await, we no longer need to worry about forgetting to dispatch back to the main thread because it has been handled by the
MainActor
. - Async/await provides greater safety from thread explosion while simultaneously improving the performance of our code. You can check out this WWDC video to find out more.
- Asynchronous code written using the async/await syntax is all straight-line code. The operations that need to be performed in sequence are all listed one after the next. This makes our code (implementation & call site) shorter, cleaner, and easier to reason about. You can use the following images to make a quick comparison.
Further Readings
- Preventing Data Races Using Actors in Swift
- The Actor Reentrancy Problem in Swift
- How Sendable Can Help in Preventing Data Races
Wrapping Up
There you have it! Making a network request using async/await is pretty straightforward, and we gain tons of benefits by just simply using it. However, it is worth noting that async/await is only available in iOS 15 and up. Therefore, if your project still needs to support an older version of iOS, you might need to wait a while before updating your existing asynchronous code to use async/await.
Here’s the full sample code if you would like to try it out yourself.
If you like this article and would like to get notified when new articles come out, feel free to follow me on Twitter and subscribe to my monthly newsletter.
Thanks for reading. 👨🏻💻
👋🏻 Hey!
While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.
- ✨ Bartender: Superpower your menu bar and take full control over your menu bar items.
- ✨ CleanShot X: The best screen capture app I’ve ever used.
- ✨ PixelSnap: Measure on-screen elements with ease and precision.
- ✨ iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.