Why use async/await vs completion handlers?

I'm pretty sure I'm missing something completely obvious, but I can't see what. I watched WWDC session, read the Swift evolution blog, and they all make sense, but still it doesn't click for me. Please help me out here :) .

I'm diving into adopting the 'new' async/await style of coding (I know, it's old news at this point, but I could only get to it now), and so I'm all pumped to get my code to go eleven and therefore I wrote a small data-downloader class. It has one method, well two: one oldskool function with a completionHandler, and one new style async/await one.

When using the oldskool one, it works as everyone would expect:

print(1)
dataFetcher.fetchSomeData
{
    print(2)
    let data = $0
    // process data ...
    print(3)
}
print(4)

The output is, unsurprisingly:

1
4
2
3

Now, when I use my new style function:

let data = await dataFetcher.fetchSomeData()
//  process data ...

Xcode gives me an error:

'async' call in a function that does not support concurrency

That makes sense, I am calling this in the viewDidLoad() method of a UIViewController subclass. Can't mark viewDidLoad() as await, as super's implementation is not async. No problem, let me wrap it in Task:

print(1)
Task
{
    print(2)
    let data = await dataFetcher.fetchSomeData()
    // process data ...
    print(3)
}
print(4)

No errors, and this code works exactly as expected, the output is:

1
4
2
3

So now I am wondering: why take the effort of changing/adding code for async/await style function that ultimately end up requiring exactly the same amount of code, and is exactly as non-linear as completionHandlers?

Note that the dataFetcher only has one property, an instance ofURLSession, so I am also not even managing my own queues or threads in the oldskool method vs the new one. They just wrap URLSession's functions:

func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask

and

func download(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (URL, URLResponse)

Is async/await useful only with SwiftUI maybe? What am I missing here? Please help me see the light.

Accepted Reply

I think you have identified the benefits, but let me restate them in slightly different words:

  1. When you use completion handlers, especially for multiple successive completions, you end up with indented code getting deeper and deeper nesting in braces. This is familiarly known as the "pyramid of doom". Using async/await clarifies that your code is actually sequential, which is a big win for code readability.

  2. Using Task { … } to transition from a sync to an async context makes it clear that viewDidLoad returns without waiting for the task to run. You already know this — which is why you expect the order 1, 4, 2, 3 — but it's a mental leap that developers new to Swift have to make.

It's also worth adding that adopting async/await also opens up the world of Swift actors, which are the language's way of helping you avoid data races and other concurrency problems.

Replies

Yes, you miss something. With such a simple example, little interest in fact. But real case, with multiple calls, are different.

There are tons of articles trying to answer your question. I found this one interesting.

https://www.hackingwithswift.com/articles/233/whats-new-in-swift-5-5

Hope that helps.

Thanks for sharing. I think that article made me see 2 possible benefits, correct me if I’m wrong:

  1. if there is code that never makes its way up to a synchronous context, code will look more linear. Example: downloading some data and persisting it, without directly surfacing the new content in the ui
  2. code that does make its way into a sync context (like my example), will have only a sinlge place where the code is non-linear. Everything behind that function call can be impkemented wit async/await, simplifying the underlying code possibly a lot

Is that about right? Anything else I am missing?

I think you have identified the benefits, but let me restate them in slightly different words:

  1. When you use completion handlers, especially for multiple successive completions, you end up with indented code getting deeper and deeper nesting in braces. This is familiarly known as the "pyramid of doom". Using async/await clarifies that your code is actually sequential, which is a big win for code readability.

  2. Using Task { … } to transition from a sync to an async context makes it clear that viewDidLoad returns without waiting for the task to run. You already know this — which is why you expect the order 1, 4, 2, 3 — but it's a mental leap that developers new to Swift have to make.

It's also worth adding that adopting async/await also opens up the world of Swift actors, which are the language's way of helping you avoid data races and other concurrency problems.

Yes right, thank you. Your first point is indeed a better phrased version of mine. The 2nd point make sense too, and did not occur to me. I will dive into Swift actors, and look forward to learning how these will help me simplify and strengthen my code. Thanks for going along with me and help me structure my thoughts!

I've been wondering the same thing lately, and I think the answer depends on the complexity of the problem.

In the example you provided, there might be little (or no) benefit using async/await vs completion handlers. However, consider the following example:

func fetchAllData(firstId: String, completion: @escaping (Result<ImportantData, Error>) -> Void) { 
    fetchInitialData(firstId) { [weak self] result in 
        switch result {
        case .success(let initialData): 
            self?.fetchSecondaryData(initialData.id) { result in 
                switch result { 
                case .success(let secondaryData):
                    self?.fetchFinalData(secondaryData.id) { result in 
                        switch result { 
                        case .success(let imporantData): completion(.success(importantData))
                        case .failure(let finalError): completion(.failure(finalError)
            }
                case .failure(let secondError): completion(.failure(secondError)
                }
        case .failure(let firstError): completion(.failure(firstError)
        }
    }
}

A silly example, to be sure, but I'm sure there are times when developers need to use multiple asynchronous methods in a synchonrous fashion (if that makes sense). each subsequent call requires info from the previous. And the above example is assuming no other work aside from another async call is being made on each success. Before async/await, that nightmare above was probably a 'good' solution.

The same code using async/await:

func fetchAllData(firstId: String) async throws -> ImportantData { 
    let initialData = try await fetchInitialData(firstId)
    let secondaryData = try await fetchSecondaryData(initialData.id)
    
    return try await fetchFinalData(secondaryData.id)
}

It's almost comical how simple async/await can make these complex nested completion handlers. And even if you wanted to keep the outer-most completion handler, you can still clean things up with async/await but using a Task inside of the outermost method alongside a do/catch:

func fetchAllData(firstId: String, completion: @escaping (Result<ImportantData, Error>) -> Void) { 
    Task {
        do {
            let initialData = try await fetchInitialData(firstId)
            let secondaryData = try await fetchSecondaryData(initialData.id)
            let importantData  = try await fetchFinalData(secondaryData.id)

            completion(.success(importantData)) 
        } catch { 
            completion(.failure(error))
        }
    }
}

In the last example, you'll need to decide when to switch back to the MainActor (MainThread) if any UI work is being done with the ImportantData. Ideally Threading concerns should be isolated to a specific file, but I don't think it would be too ridiculous to use await MainActor.run { completion(/*result here*/) } on the final completions if necessary.