RunLoop.main vs DispatchQueue.main: The differences explained

RunLoop.main and DispatchQueue.main are often used as schedulers within Combine. During code reviews, I often encounter inconsistency in using one or another, which made me realize it’s not always clear what the differences are. You might be surprised by the outcome of this article!

While you can use both RunLoop.main and DispatchQueue.main in different scenarios, I will focus on its usage within Combine for this article. You can assume that the same explanation applies to methods without using Combine.

What is a Combine scheduler?

A Combine scheduler defines when and how to execute a closure. Schedulers conform to the Scheduler protocol to which both RunLoop.main and DispatchQueue.main conform.

The most common example is using receive(on:options:) when setting up a Combine stream:

URLSession.shared
    .dataTaskPublisher(for: URL(string: "https://picsum.photos/300/600")!)
    .map(\.data)
    .compactMap(UIImage.init)

    /// Schedule to receive the sink closure on the
    /// main dispatch queue.
    .receive(on: DispatchQueue.main, options: nil)
    
    .sink { _ in
        print("Image loading completed")
    } receiveValue: { image in
        self.image = image
    }.store(in: &cancellables)

As you can see in the above code example, we’re making sure to receive the image on the main thread using the DispatchQueue.main scheduler. The main queue scheduler is required since we need to perform UI updates on the main thread.

What is RunLoop.main?

A RunLoop is a programmatic interface to objects that manage input sources, such as touches for an application. A RunLoop is created and managed by the system, who’s also responsible for creating a RunLoop object for each thread object. The system is also responsible for creating the main run loop representing the main thread.

What is DispatchQueue.main?

DispatchQueue.main is the dispatch queue associated with the main thread of the current process. The system is responsible for generating this queue which represents the main thread. A dispatch queue executes tasks serially or concurrently on its associated thread.

If you want to read more about dispatch queues, I encourage you to read my article Concurrent vs Serial DispatchQueue: Concurrency in Swift explained.

RunLoop.main and DispatchQueue.main are the same but different

Both RunLoop.main and DispatchQueue.main execute their code on the main thread, meaning that you can use both for updating the user interface. Both schedulers allow us to update the UI after a Combine value was published, which is why there’s no apparent reason stopping us from using one or another. Though, there are some essential differences to know.

The differences between RunLoop.main and DispatchQueue.main

The most significant difference between RunLoop.main and DispatchQueue.main is that the latter executes directly while the RunLoop might be busy. For example, presenting a downloaded image while scrolling will only immediately show when using the DispatchQueue.main as a scheduler:

RunLoop.main (left) vs. DispatchQueue.main (right): loading an image while scrolling.

As you can see in the above recording, the image is only updated after scrolling when using RunLoop.main:

URLSession.shared
    .dataTaskPublisher(for: URL(string: "https://picsum.photos/300/600")!)
    .map(\.data)
    .compactMap(UIImage.init)

    /// Scheduling using RunLoop.main delays closure
    /// execution until scrolling stops.
    .receive(on: RunLoop.main, options: nil)

    .sink { _ in
        print("Image loading completed")
    } receiveValue: { image in
        self.image = image
    }.store(in: &cancellables)

In other words: the execution of closures scheduled on the main run loop will be delayed for execution whenever user interaction occurs.

Understanding the behaviour of the main RunLoop

To better understand why the above image is not updating during scrolling, it’s good to understand what’s happening when you start scrolling. The RunLoop.main uses several modes and switches to a non-default mode when user interaction occurs. However, RunLoop.main as a Combine scheduler only executes when the default mode is active. In other words, the mode is switched back to default when user interaction ends and the Combine closure executes.

So, should I ever use RunLoop.main?

My advice would be to use the DispatchQueue.main by default as a Combine scheduler when you want to update the user interface. It’s unlikely that you want to wait for user interaction to end before processing new updates to your views.

However, updating your UI while scrolling might affect the frames per second (FPS) and smooth scrolling. It could be that your UI update isn’t as necessary when the user is scrolling. In that case, it might make sense to opt-in to using RunLoop.main as a scheduler.

Conclusion

RunLoop.main and DispatchQueue.main have much in common but come with a significant difference that can lead to unexpected behavior in our apps. Since both schedulers allow us to update the user interface, there’s no apparent reason to use one or another. Hopefully, after reading this article, you’re better aware of the differences and when to use which.

To read more about Swift Combine, take a look at my other Combine blog posts: