Articles, podcasts and news about Swift development, by John Sundell.

Deciding what DispatchQueue to run a completion handler on

Published on 06 May 2021
Discover page available: Concurrency

When it comes to calling completion handlers for asynchronous operations, the established convention within the Apple developer community has for long been to simply continue executing on whatever DispatchQueue that the operation itself (or at least its final part) was performed on.

For example, when using the built-in URLSession API to perform a data task-based network call, our attached completion handler will be executed on a queue that’s managed internally by URLSession itself:

let task = URLSession.shared.dataTask(with: url) {
    data, response, error in

    // This code will be executed on an internal URLSession queue,
    // regardless of what queue that we created our task on.
    ...
}

The above convention does arguably make complete sense in theory — as it encourages us to write asynchronous code that’s non-blocking, and since it tends to reduce the overhead involved in jumping between queues when doing so isn’t needed. However, it can also very often lead to different kinds of bugs and race conditions if we’re not careful.

That’s because, at the end of the day, the vast majority of the code within the vast majority of applications isn’t going to be thread-safe. Making a class, function, or another kind of implementation thread-safe typically involves a fair amount of work, especially when it comes to UI-related code, since all of Apple’s core UI frameworks (including both UIKit and SwiftUI) can only be safely used from the main thread.

Remembering to dispatch UI updates on the main queue

Let’s take a look at an example, in which we’ve built a ProductLoader that uses the above mentioned URLSession API to load a given Product based on its ID:

class ProductLoader {
    typealias Handler = (Result<Product, Error>) -> Void

    private let urlSession: URLSession
    private let urlResolver: (Product.ID) -> URL
    
    ...

    func loadProduct(withID id: UUID,
                     completionHandler: @escaping Handler) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            data, response, error in

            // Decode data, perform error handling, and so on...
            ...
            
            handler(result)
        }

        task.resume()
    }
}

The above class follows that established convention of not dispatching its completionHandler call on any specific queue, and instead simply calls that closure inline within its own completion handler, which is in turn called by URLSession on that previously mentioned internal background queue.

Because of that, whenever we’re using our ProductLoader within any kind of UI-related code, we need to remember to always explicitly dispatch any resulting UI updates on our application’s main DispatchQueue — for example like this:

class ProductViewController: UIViewController {
    private let productID: Product.ID
    private let loader: ProductLoader
    private lazy var nameLabel = UILabel()
    private lazy var descriptionLabel = UILabel()
    
    ...

    func update() {
        loader.loadProduct(withID: productID) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let product):
                    self?.nameLabel.text = product.name
                    self?.descriptionLabel.text = product.description
                case .failure(let error):
                    self?.handle(error)
                }
            }
        }
    }
}

Having to remember to perform the above kind of DispatchQueue calls within our asynchronous closures might not actually be a huge issue in practice, since (just like the classic weak self dance) it’s something that we have to do incredibly often when developing apps for Apple’s platforms, so it’s not something that we’re likely to forget.

Plus, if we ever do forget to add that call (or if someone’s just getting started with app development and haven’t learned about that aspect yet), then Xcode’s Main Thread Checker will quickly trigger one of its purple warnings as soon as we run any code that accidentally calls a main queue-only API from a background thread.

However, what if we’re not using closures? For example, let’s imagine that our ProductLoader instead used the delegate pattern, and rather than calling a completion handler, it would instead call a delegate method whenever it finished one of its operations:

class ProductLoader {
    weak var delegate: ProductLoaderDelegate?
    ...

    func loadProduct(withID id: UUID) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            [weak self] data, response, error in

            guard let self = self else { return }

            // Decode data, perform error handling, and so on...
            ...
            
            self.delegate?.productLoader(self,
    didFinishLoadingWithResult: result
)
        }

        task.resume()
    }
}

If we now go back to our ProductViewController and update it accordingly, it’s no longer very clear that the call site (its delegate protocol implementation in this case) is handling the result of an asynchronous operation, which makes it much more likely that we’ll forget to perform our UI updates asynchronously on the main queue.

So although Xcode will still give us a runtime error when the following method is called (and our UI updates are performed on a background queue), it’s not very obvious that its implementation is incorrect just by looking at it:

extension ProductViewController: ProductLoaderDelegate {
    func productLoader(
        _ loader: ProductLoader,
        didFinishLoadingWithResult result: Result<Product, Error>
    ) {
        switch result {
        case .success(let product):
            nameLabel.text = product.name
            descriptionLabel.text = product.description
        case .failure(let error):
            handle(error)
        }
    }
}

Granted, the delegate pattern is not as hip and trendy as it used to be (I still like it, though), but the above problem is definitely not unique to that particular pattern. In fact, if we now look at a very modern, Combine-based version of our ProductLoader and its associated view controller — we can see that it has the exact same problem as our delegate-based implementation — it’s not at all obvious that our UI updates will currently end up being performed on a background queue:

class ProductLoader {
    ...

    func loadProduct(withID id: UUID) -> AnyPublisher<Product, Error> {
        urlSession
            .dataTaskPublisher(for: urlResolver(id))
            .map(\.data)
            .decode(type: Product.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

class ProductViewController: UIViewController {
    ...
    private var updateCancellable: AnyCancellable?

    func update() {
        updateCancellable = loader
            .loadProduct(withID: productID)
            .convertToResult()
            .sink { [weak self] result in
                switch result {
                case .success(let product):
                    self?.nameLabel.text = product.name
                    self?.descriptionLabel.text = product.description
                case .failure(let error):
                    self?.handle(error)
                }
            }
    }
}

Above we’re using the custom convertToResult operator from “Extending Combine with convenience APIs” to be able to easily handle our Combine pipeline’s output as a Result value.

So, to summarize, regardless of which pattern that we choose to implement our asynchronous operations, there’s always a risk that we’ll forget to manually dispatch our UI updates on the main queue — especially when it’s not obvious that a given callback might be performed on a background queue.

Explicit queue injection

So how can we fix the above problem? Is it even worth fixing, or should we just assume that every Swift developer with a certain amount of experience will always remember to ensure that their UI updates will be performed on the main queue?

If you ask me, I think that any truly great API shouldn’t rely on its caller remembering (or even knowing) certain conventions — those conventions should ideally be baked into the API design itself. After all, a quite rock-solid way to ensure that an API doesn’t ever get used incorrectly is to make it impossible (or at least very hard) to do so — by leveraging tools like Swift’s type system to validate each call at compile time.

One way to do that in this case would be to always call our completion handlers on the main queue, which would completely eliminate the risk of having any of our call sites accidentally perform UI updates on a background queue:

class ProductLoader {
    ...

    func loadProduct(withID id: UUID,
                     completionHandler: @escaping Handler) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            data, response, error in

            ...

            DispatchQueue.main.async {
    completionHandler(result)
}
        }

        task.resume()
    }
}

However, the above pattern can also end up causing issues of its own, especially if we’re looking to use our ProductLoader within contexts where we do want to continue executing on a background queue in a non-blocking way.

So here’s a much more dynamic version, which still uses the main queue as the default for all completion handler calls, but also enables an explicit DispatchQueue to be injected — giving us the flexibility to both use our ProductLoader within concurrent environments that operate away from the main thread, and within our UI code, all while significantly reducing the risk of performing UI updates on the wrong queue:

// Completion handler-based version:

class ProductLoader {
    ...

    func loadProduct(
        withID id: UUID,
        resultQueue: DispatchQueue = .main,
        completionHandler: @escaping Handler
    ) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            data, response, error in

            ...

            resultQueue.async {
    completionHandler(result)
}
        }

        task.resume()
    }
}

// Combine-based version:

class ProductLoader {
    ...
    
    func loadProduct(
        withID id: UUID,
        resultQueue: DispatchQueue = .main
    ) -> AnyPublisher<Product, Error> {
        urlSession
            .dataTaskPublisher(for: urlResolver(id))
            .map(\.data)
            .decode(type: Product.self, decoder: JSONDecoder())
            .receive(on: resultQueue)
            .eraseToAnyPublisher()
    }
}

Of course, the above pattern does rely on us remembering to add that resultQueue argument to each of our asynchronous APIs (we could’ve also implemented it as an initializer parameter instead), but at least now we don’t have to remember to always use DispatchQueue.main.async at every single call site — which I personally think is a big win.

Conclusion

While there’s no such thing as a completely error-proof API, and developing apps for any kind of platform is always going to involve learning and remembering certain conventions, if we can make the APIs that we design within our own apps as easy to use (or as hard to misuse) as possible, then that tends to result in code bases that are robust and straightforward to work with.

Defaulting to calling completion handlers on the main queue might just be a small part of that, but it might turn out to be a quite important part, especially within code bases that make heavy use of asynchronous operations that result in UI updates.

If you want to hear more thoughts on this topic, then I really recommend listening to my recent podcast conversation with Brent Simmons, which focused on how to orchestrate concurrent code within iOS and Mac apps.

Thanks for reading!