Async/Await, Combine, Closures: A Guide to Modern Asynchronous Swift

With async/await Apple introduced yet again another way of making asynchronous calls in Swift. There are now three different ways of making asynchronous calls: Completion handlers, Combine and async/await – If you also take community solutions like RxSwift or ReactiveSwift into account there are even more.

What are we actually supposed to use now? Is there an obvious winner or is it once again in the detail? Let’s find out!


The Approaches to Asynchronous Swift code

Let’s first recap the three different approaches to how asynchronous code can be written in Swift. In this article we will cover closures, reactive programming (with Combine) and async/await.

To illustrate this with a common example, we want to write a function that creates a URLSessionDataTask to retrieve data from the network. It should further validate the response by checking its status code and throw an error if the status code is not between 200 and 399.

Note: In production code, you should probably handle a non-successful http response more extensively than how it is covered here, e.g. by parsing the returned data for a potential error message from the server.

Closures

Completion handlers are closures (i.e. functions) that are injected into methods as parameter(s). These closures are performed when a method’s asynchronous activity is finished returning the respective result. In the example method, this would either be the data retrieved from the server or an error.

func perform(_ request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
    let task = URLSession.shared.dataTask(for: request) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data, 
              let httpResponse = response as? HTTPURLResponse, 
              (200..<400).contains(httpResponse.statusCode) else {
            completion(.failure(URLError(.badServerResponse)))
            return
        }
        completion(.success(data))
    }
    task.resume()
}

As you can see in the example above, our method parameter list has a completion handler closure as its last parameter. When the dataTask has finished (either successfully or not), we want to call that closure.
Adding completion handler support to a method is quite simple: Add a new closure input parameter to your method that is called whenever the asynchronous action is finished.
There are, however, a few issues with that approach:

  1. A completion handler can be called any number of times. It is not enforced by the compiler that the closure is called in every possible branch of execution. It could also be called twice. As shown in the example code snippet above, this is especially relevant for guard statements or if clauses containing control flow statements (return, continue, break,…).
  2. Calling asynchronous actions one after the other is accomplished by nesting actions in completion handlers of other actions. This can easily lead to hard-to-read code when many actions are chained together.

Let me show you an example adapted from the async/await proposal that showcases the second issue:

func processImageData(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        do {
            let dataResource = try dataResourceResult.get()
            loadWebResource("imagedata.dat") { imageResourceResult in
                do {
                    let imageResource = try imageResourceResult.get()
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        do {
                            let imageTmp = try imageTmpResult.get()
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        } catch {
                            completionBlock(.failure(error))
                        }
                    }
                } catch {
                    completionBlock(.failure(error))
                }
            }
        } catch {
            completionBlock(.failure(error))
        }
    }
}

Reactive Programming: Combine

In reactive programming, a subscription is created between a publisher emitting values that are received by a subscriber. The subscription can further be completed by the publisher by either closing the subscription or failing with an error. The subscriber can also cancel the subscription.

func perform(_ request: URLRequest) -> AnyPublisher<Data, Error> {
    URLSession.shared.dataTaskPublisher(for: request)
        .tryMap { data, response in
            guard let httpResponse = response as? HTTPURLResponse,
                  (200..<400).contains(httpResponse.statusCode) else {
                throw URLError(.badServerResponse)
            }
            return data
        }
        .eraseToAnyPublisher()
}

In reactive programming a publisher can be created by applying operators to existing publishers. In the example above, we first have a publisher that returns values of type (Data, URLResponse) which we then map to a single Data object. We use the tryMap operator because that operation involves checking the response’s status code, which might fail. As a last step (which is mainly relevant for Combine and not reactive programming in general), we erase the publisher’s type, so that the method’s signature does not contain any specific publisher type, but instead we want to use AnyPublisher as to be able to easily change the method’s content without modifying its signature.

In case you are not familiar with reactive programming and would like to have a more thorough look, your read this article where about wrote about the transition from RxSwift to Combine with an overall introduction to the general topic.
Reactive programming with its intensive use of operators creates quite a bit of code overhead. This is especially the case for Combine, where you will need to type-erase any publisher return type to AnyPublisher for to be able to change the method’s content without changing its signature alongside it. This is especially the case where services or business logic is abstracted through the use of protocols. The method signature should not contain specific concrete publisher types.

In contrast to closures, failing a publisher with an error is already baked into the framework itself. In Combine, you will however often need to convert publishers between different error types (or non-throwing and throwing publishers) creating additional boilerplate code.

Async/Await

With Swift 5.5, Apple released Structured Concurrency including async/await, the actor concept, asynchronous sequences, and task support. For a full list of proposals leading to the introduction of this feature, have a look at the end of the article.

func perform(_ request: URLRequest) async throws -> Data {
    let (data, response) = try await URLSession.shared.data(for: request)
    guard let httpResponse = response as? HTTPURLResponse,
          (200..<400).contains(httpResponse.statusCode) else {
        throw URLError(.badServerResponse)
    }
    return data
}

While looking quite similar to the Combine code, it eliminates some of the code overhead entirely. Have you noticed the missing tryMap operator alongside its closure and the eraseToAnyPublisher call? Async/await methods look quite similar to regular, synchronous methods with the small difference of containing the async keyword. Think of these methods as somewhat of a different kind of method entirely, since you cannot call them from any other method. You can only call async methods either from within another async method or from within a Task.
Async/await allows the developer to write code that looks very much like synchronous code with the exception of using the async and await keywords. In comparison to reactive programming and closures, it further compresses boilerplate code to individual keywords (mainly async and await).
In contrast to closures that could be called any number of times, async/await enforces a single return value and reminds the developer about missing return values after the return statement and at the end of a method.

Features

Now that we have seen how to write asynchronous code in these different styles, let’s see how they behave in situations where more features are required. Specifically, how they can be used for streams of values, how they handle failure events, whether or not they can be canceled, and more.

✍️ Output

An asynchronous task can either have only one single output when the operation is finished or even multiple ones. For example, when you want to notify about the progress of an operation or a value might simply change over time, let’s say a timer or a network value that is regularly updated.

Closures

Closures do not inherently give any information about how many times they are called, so we can use this to our advantage here. For a stream of values, we can simply call the closure parameter with different values over time.

func doSomething(update: @escaping (Update) -> Void, completion: @escaping (FinalResult) -> Void) {
    // ...
}

The example method contains two closure parameters. The first parameter update is called whenever there is an update available for the given operation (e.g. new progress), while completion is called when the operation is over. Since closure parameters are commonly only called once an operation has finished, we advise adding a note to the method’s documentation as well to clarify that a closure parameter could be called multiple times.

Combine

Combine publishers are not in general bound by a specific amount of values they publish. Some concrete publisher types make it clear that a specific amount of values are emitted:

However, once we type-erase a method’s return type or use many operators, this information is lost and can only be deduced by the method’s signature or the documentation.
The compiler can therefore not ensure that there will be an output value at all, exactly one or possibly infinitely many without ever stopping (that would be the case for a repeating timer).
A publisher can also signalize the end of a subscription, but cannot add any further information (like a final result). This could be realized with a final Update value that contains the final result or more complex solutions involving Subjects, Subscribers or closures for the update propagation and a single-value publisher.

Async/await

Marking a function as async will tell the compiler that there will only be a single return value, just like in a synchronous function.
However with the use of AsyncSequence types, we can create streams of values similar to reactive publishers. We can then listen to these values using for-await-in-loops as shown in the following example:

let url: URL = // ...
for try await line in url.lines {
    print(line)
}
// when this line is executed, the file at the given url has been fully read.

The above code example reads a file at the given URL line by line resulting in the for-await-in-loop’s body being called for each line (the code Documentation).

Similar to Combine, AsyncSequence inform about their completion and do not allow to add a final result. You automatically listen to this event with a for-await-in-loop, since after a completion event is observed, the code after the loop is executed. To accomplish both an update and a completion mechanism, closures can be used for the update mechanism in a async function with a return value.

⚠️ Failure

Just like synchronous operations asynchronous operations can fail. Think of network calls, database accesses or file operations for example. So, how do these approaches handle that?
Especially in low-level code, you might also want to specify a concrete error type that can be thrown, so do these approaches allow for that as well?

Closures

Objective-C blocks or less modern Swift code often use completion handler closures with two parameters and one being an optional error. Swift 5 introduced the Result type to easily make it clear that either a value is returned or a failure, but both and also not nothing at all. By specifying a concrete Failure in the result type, the possible error type can also be constrained.

func doSomethingOld(completion: @escaping (Success?, Error?) -> Void) { ... }
func doSomethingNew(completion: @escaping (Result<Success, Error>) -> Void) { ... }

For more low-level code throwing only errors of a single error type, that type can be specified as the second generic constraint of Result. Example: Result<String, URLError>.

Combine

To take a look at error handling in Combine, let’s have a peek at the Publisher protocol:

protocol Publisher {
    associatedtype Output
    associatedtype Failure : Error
    func receive<S: Subscriber>(subscriber: S) where Failure == S.Failure, Output == S.Input
}

As we can see from this protocol, every publisher needs to have an associated Failure type. So does that mean, that each publisher can fail? No. Whenever a publisher cannot fail, it can specify the Never type as its Failure type. Never is a type that no objects can exist of. It pretty much means that it does not and cannot exist. In practice, Never is an enum without any cases, so that should pretty much make it clear, why it is not possible to create any values of that type.
Many publishers have their Failure type as a generic constraint: see Future<Output, Failure>, AnyPublisher<Output, Failure> and many more. For publishers resulting from operators, the failure type is often implied by the upstream publisher and the operation. For example, a tryMap operation creates a publisher with an associated Failure type of Error, which could be any error value that conforms to the Error protocol.
To summarize: Combine not only supports error handling out of the box, you can further constrain the error type to a specific type.

Async/await

Functions marked as async can also be marked with the throws keyword, so similar to how synchronous methods can fail, asynchronous ones can too! However, it does not support specialized failure types. This is mostly due to the language’s missing support for specialized failure type for throwing functions in general. A swift-evolution proposal has already entered the pitch phase in August 2020 to allow functions to specify a failure type. It somehow lost traction in the community since then, so we will see, if this will ever be implemented into the Swift language or not.
AsyncSequence can also throw errors resulting in the completion of the stream of values. Due to similar language constraints, the failure type can also not be further specified here.

Cancellation

Some asynchronous operations might take ages without failing or even created to not complete by itself altogether (a timer for example). At some point, the user might no longer be interested in the completion of the operation and intend to cancel it. How do we accomplish that with closures, reactive programming and async/await though?

Closures

Closures generally do not support cancellation. One can, of course, write a mechanism to introduce cancellation support for individual methods, but there is currently no standardized way (that I know of) to do this. Existing constructs for this concept can be found in the Timer type, that needs to be referenced strongly somewhere in your code to continue emitting values.

Combine

Combine supports cancellation out of the box. Simply call cancel() on any Cancellable that is returned when subscribing to a publisher and the operation is stopped and no more values are emitted.

Async/await

Task has a cancel() function that works very similar to how subscriptions are canceled in Combine.

In contrast to reactive programming where every operator can check for cancellation, you will need to check for cancellation throughout your code by yourself. Otherwise, the action would further be executed. This allows for more control over when the action is stopped but also might make it difficult to understand why a method’s operation keeps getting executed, even though it has been cancelled already.

To check for cancellation events in your code, use try Task.checkCancellation() or Task.isCancelled. Further, you can also add cancellation handlers to be called exactly when the cancellation is initiated by using the withTaskCancellationHandler method.

🔙 Back-pressure Support

Let’s say, we have a user interface with a slider that emits values when the user interacts with it. Next to it, there is a label that should always show the currently selected value. Now, when the user slides, a stream emits values. While the first update is still computing the new text, the third update is already available. We can therefore safely ignore update 2 since it would be a waste of resources to compute its result if it is immediately thrown away anyways.

Closures

No. This could definitely be accomplished in some form or another but would require additional efforts rather than being available for free.

Combine

Combine has back-pressure support. This is, however, a major difference of Combine to other reactive programming libraries such as RxSwift and ReactiveSwift.

Async/await

Async/await has back-pressure support. When you iterate over an async sequence, intermediate values are thrown away automatically, if the body of the for-loop is still being executed with one of the previous values of the stream.

➗ Operators

In complex applications, different asynchronous operations and/or value streams need to be combined together into a single operation or a single stream. Value streams with a high refresh rate might need throttling, state updates might be triggered by the change of one or many other state variables, a task might need to be run in regular intervals and more come to mind. Seems pretty hard to implement? Let’s see how much custom code the different approaches are required for the more complex operations.

Closures

There might certainly be options to combine and manipulate data coming from closures, but it often involves custom code with low-level constructs like NSLock or DispatchSemaphore. Further, issues involving non-exclusive access to variables from different execution contexts can easily arise, if not carefully thought through.

Combine

Combine already provides a set of operators out of the box, including the more basic map, flatMap and reduce similar to how arrays and other sequences work in Swift. On top of that, multiple publishers can also be combined in different ways, see merge, combineLatest or zip.
As we have previously discussed in this article, Combine’s operator range is less extensive than what is available for RxSwift or ReactiveSwift. If you heavily rely on asynchronous streams of data that need to be manipulated and combined in rather unconventional ways, you might want to have a look at third-party Combine extension libraries or these alternative reactive programming libraries altogether.

Async/await

AsyncSequences have many of the common sequence operators of Swift available (including map, flatMap and reduce). Swift’s standard library does not go beyond that, however. You can however add package dependencies to get more features like AsyncAlgorithms by Apple or CollectionConcurrencyKit by John Sundell.
As noted as a future direction in this proposal, there might soon be support for a reasync mechanism in Swift similar to rethrows and as described here this might even lead to the merger of the AsyncSequence and Sequence protocols making it very easy to use existing sequence operators on async sequences in the future.

🎛 Switching execution contexts

Some actions simply need to be performed on the main thread, while more computation-intensive tasks need to be run on a background thread. How do you make sure that code is performed in a specific execution context using closures, Combine or async/await?

Closures

Execution contexts are changed in the form of calling sync or async on a specific DispatchQueue.

func doSomething(completion: @escaping (Result<String, Error>) -> Void) {
    DispatchQueue.global(qos: .background).async {
        doAnotherThing { result in
            DispatchQueue.main.async {
                completion(result)
            }
        }
    }
}

Combine

The receive(on:) operator allows switching the execution context of further processing, while subscribe(on:) changes the execution context of the subscription to a publisher.

func doSomethingPublisher() -> AnyPublisher<String, Error> {
    doAnotherThingPublisher()
        .subscribe(on: DispatchQueue.global(qos: .background))
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Async/await

Changing to a background thread is done by creating new Tasks with an appropriate priority (or none at all), while switching to the main thread is done using MainActor.run, @MainActor closures or by assigning a @MainActor property (or a property of a @MainActor attributed class).

func doSomething() async throws -> String {
    let task = Task(priority: .background) {
        let returnValue = try await doAnotherThing()
        
        // This makes sure that the return value is returned on the main thread.
        // In many cases, it might, however make more sense to mark variables 
        // that should only be set on the main thread with `@MainActor`.
        return await MainActor.run { returnValue } 
    }
    return try await task.value
}

Interfacing

Now that we have looked at all of the features, you might want to try out a new approach. But rewriting your whole app takes a lot of work and might introduce new bugs. So, let’s see how you can write only part of your app using one style and use it coming from one of the other styles. We start with how you can convert closures or Combine publishers for use in async/await contexts. For a comprehensive list of all the possible approaches and their most elegant conversion, have a look at our gist: Here

To async/await

Converting closure-based operations to async/await is done using of the following four mechanisms:

  1. Single-Value Non-Throwing:
await withCheckedContinuation { continuation in
    doSomething { value in
        continuation.resume(returning: value)
    }
}

2. Single-Value Throwing: withCheckedThrowingContinuation

await withCheckedContinuation { continuation in
    doSomething { result in
        continuation.resume(with: result) 
    }
}

3. Multi-Value Non-Throwing: AsyncStream

AsyncStream { continuation in
    doSomething { update in
        continuation.yield(update)
        // you might want to call `continuation.finish()` at some point!
    }
}

4. Multi-Value Throwing: AsyncThrowingStream

AsyncThrowingStream { continuation in
    doSomething { value in
        switch value {
        case let .success(success):
            continuation.yield(success)
        case let .failure(error):
            continuation.yield(with: .failure(error))
        }
    }
}

For Combine publishers, we can simply use their values property. Depending on the Failure type of the publisher, these AsyncSequences will either be throwing or non-throwing. For single-value use, we can append .first(where: { _ in true })! to simply only take the first value of that publisher and be able to use it in a regular async function. Note however, that the force unwrapping will crash your app, if the publisher completes without ever publishing a value or emitting an error.

To Combine

Creating Combine publishers normally requires creating a specific type with an associated subscription type involving a lot of code for quite a simple issue. To make this a lot easier, we created the following extension to `AnyPublisher` that allows us to create a publisher with the use of a simple closure.

extension AnyPublisher {

    init(builder: @escaping (AnySubscriber<Output, Failure>) -> Cancellable?) {
        self.init(
            Deferred<Publishers.HandleEvents<PassthroughSubject<Output, Failure>>> {
                let subject = PassthroughSubject<Output, Failure>()
                var cancellable: Cancellable?
                cancellable = builder(AnySubscriber(subject))
                return subject
                    .handleEvents(
                        receiveCancel: {
                            cancellable?.cancel()
                            cancellable = nil
                        }
                    )
            }
        )
    }
}

async/await: Task already has a cancel() method that we can easily use to make it conform to the Cancellable protocol. With that, we can then simply return a Task from the closure in the initializer above.

AnyPublisher { subscriber in
    Task {
        do {
            for try await value in self {
                subscriber.receive(value)
            }
            subscriber.receive(completion: .finished)
        } catch {
            subscriber.receive(completion: .failure(error))
        }
    }
}

For closures: Since closures cannot be cancelled, we do not need to think about the return value of the above initializer, since it would always simply be nil. Keep in mind that closures could be called multiple times, so depending on whether or not that should happen, you should probably also call subscriber.receive(completion: .finished) at some point. For a normal completion handler, this would occur directly after subscriber.receive(value)

func doSomethingPublisher() -> AnyPublisher<Value, Error> {
    AnyPublisher { subscriber in
        doSomething { result in
            switch result {
            case let .success(value):
                subscriber.receive(value)
            case let .failure(error):
                subscriber.receive(completion: .failure(error))
            }
        }
        return nil
    }
}

To Closures

async/await: Create a Task to move into an asynchronous context, then call the closure when applicable.
– Note: Use MainActor.run { ... } for when you want to call the closure on the main thread.

func doSomething(handler: @escaping (Result<Value, Error>) -> Void) {
    Task {
        do {
            // this could of course also be a for-await-in-loop
            let value = try await doSomething() 
            await MainActor.run { handler(.success(value)) }
        } catch { 
            await MainActor.run { handler(.failure(error)) }
        }
    }  
}

Combine: You can subscribe to any publisher with the sink(receiveCompletion:receiveValue:) method. Since Combine subscriptions are cancelled, when the result of that method (its Cancellable) is deallocated, you will need to make sure to keep a reference to it until after the subscription completed.

func doSomething(handler: @escaping (Result<Value, Error>) -> Void) {
    var cancellable: Cancellable?
    cancellable = doSomethingPublisher()
    	    .sink { completion in
    	            switch completion {
                case let .failure(error):
                    handler(.failure(error))
                case .finished:
                    break
                }
                _ = cancellable // this is simply to ignore Swift's warning `Variable 'cancellable' was written to, but never read`
                cancellable = nil
    	    } receiveValue: { value in
    	        handler(.success(value))
    	    }   
}

Subscriptions are cancelled, when their cancellable is not referenced anywhere (different to RxSwift). So, keep in mind to hold on to the cancellable inside your sink call.

Platform support

As with many new language features and system libraries, they are only supported for the most recent OS or language versions. Let’s see whether these approaches are supported by your application’s deployment target and which alternatives might make it work anyways.
Closures have the most widespread platform support, since they are only restricted by Swift being supported on the platform (when considering Objective-C blocks as well, this is available for all Apple development platforms). Reactive programming is also available for a large set of platforms, however using Combine will limit usage to iOS 13+, tvOS 13+, watchOS 6+ and macOS 10.15+. Async/Await is available for Swift 5.5+ (including Linux), usage on Apple platforms is limited to iOS 13+, tvOS 13+, watchOS 6+ and macOS 10.15+ though.

Overview platform availability

Disclaimer: Of course, not all APIs are necessarily available for every platform and version, even though the library or language feature is available in general.

Conclusion

Completion handlers can be quite easily ruled out as one of the worst solutions mentioned here. They can easily make your code hard to read and maintain, do not provide cancellation support, and combining multiple streams of data is quite hard.

Reactive programming provides all of the features mentioned above, while also adding some boilerplate code. It requires quite a learning curve to get really into writing reactive code and understand the difference of certain operators.

Async/await is the new kid on the block that still requires some love to mature into an equally powerful tool as reactive programming libraries. However: It makes code quite simple to read and reduces boilerplate code.
To sum it up: The era of completion handlers is over for good, the future is bright. Reactive programming and async/await both let you write cleaner, less error-prone code (when applied correctly) and provide more features. Choosing between them is up to personal preference, the need for certain operators and the specific problem at hand.


Where to go from here…

If your company needs a developer team to implement an iOS or Android app, reach out to us at: [email protected]

If you want to take a deep dive into property wrappers, this article is a great starting point:

Thanks for reading! If you enjoyed reading the article, please support us by sharing it. And if you have any questions or ideas, don’t hesitate to get in touch with us on Twitter!

Overview of the proposals for Structured Concurrency in Swift

Share via
Copy link
Powered by Social Snap

Get notified when our next article is born!

(no spam, just one app-development-related article
per month)