You are currently viewing How to Use URLSession with Async/await in Swift

How to Use URLSession with Async/await in Swift

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:

JSON response from Apple iTunes API
JSON response from Apple iTunes API

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:

  1. The withCheckedThrowingContinuation(function:_:) method is marked as async, therefore we must call it using the await keyword. On top of that, since we are using the “throwing” variant of it, we need to use the try keyword as well (just like calling a normal function that throws).
  2. 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.
  3. The return type of withCheckedThrowingContinuation(function:_:) method must match with the resume(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 docatch 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:

  1. 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.
  2. It is impossible to use a docatch 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 a docatch statement just like handling errors thrown by a normal function.
  3. 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.
  4. 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.
  5. 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.
Comparing network request implementation using async/await and closure-based URLSession
Network request implementation comparison
Comparing network request call site using async/await and closure-based URLSession
Network request call site comparison

Further Readings


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.