Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Swift Concurrency Course

Combine and Swift Concurrency: A threading risk

Many developers are migrating from Combine to Swift Concurrency. Swift Concurrency is here to stay, and Combine hasn’t received updates in recent years. While Combine is a mature framework that may not need many updates, it’s clear that the Swift team is focusing on a future with Swift Concurrency.

When migrating Combine code to Swift Concurrency, you encounter various challenges. I won’t list them in this article, nor will I assist you with the migration. If you’re looking for that, I highly recommend following my Swift Concurrency Course, which has a dedicated focus on Combine migration. In this article, I aim to highlight a significant risk that may not be immediately apparent when completing a migration.

Dispatching to a @MainActor isolation using a Combine pipeline

The Combine framework allows you to define so-called pipelines. A common example is a publisher for a given notification, followed by a sink to perform work after receiving:

NotificationCenter.default.publisher(for: .someNotification)
    .sink { [weak self] _ in
        self?.didReceiveSomeNotification()
    }.store(in: &cancellables)

In this case, we’re calling into didReceiveSomeNotification, which will do the actual work. This is a great technique to move logic into a reusable method that you can also use for other cases.

When migrating to Swift Concurrency, you’ll start to define isolation domains. You either opt-in to @MainActor default actor isolation or you explicitly mark a type using the @MainActor attribute. Imagine the latter to be true in this example:

@MainActor
final class NotificationObserver {
    
    private var cancellables: [AnyCancellable] = []
    
    init() {
        NotificationCenter.default.publisher(for: .someNotification)
            .sink { [weak self] _ in
                self?.didReceiveSomeNotification()
            }.store(in: &cancellables)
    }
    
    private func didReceiveSomeNotification() {
        /// Perform some work...
    }
}

So far, so good.

Now, let’s dive into the NotificationCenter API. I’d like to quote a relevant Swift Foundation proposal that introduces Concurrency-Safe Notifications. These are actually released in Swift 6.2, so I highly recommend reading up on this proposal, regardless. Here’s the quote from the proposal motivation section:

Notifications today rely on an implicit contract that an observer’s code block will run on the same thread as the poster, requiring the client to look up concurrency contracts in documentation, or defensively apply concurrency mechanisms which may or may not lead to issues. Notifications do allow the observer to specify an OperationQueue to execute on, but this concurrency model does not provide compile-time checking and may not be desirable to clients using Swift Concurrency.

This should ring a lot of bells! And I found out the hard way. After migrating RocketSim to Swift 6.2 and Strict Concurrency, I started testing a final build. Eventually, I ran into the following crash:

A Combine pipeline crash after migrating to Strict Concurrency.
A Combine pipeline crash after migrating to Strict Concurrency.

When posting a notification from the main thread, observers on the @MainActor will function as expected. However, as soon as I would post a notification from e.g., a detached task, the above crash would occur. This is simply because NotificationCenter expected the observer to be executed on a specific queue.

How do you stay current as a Swift developer?

Let me do the hard work and join 20,368 developers that stay up to date using my weekly newsletter:

No compile-time feedback for sink closures

A crucial aspect of this crash is that compile-time safety does not apply to sink closures at this point. To illustrate this point, I’d like to show you another way of observing notifications:

A strict concurrency check fails for a non-Combine NotificationCenter observer method.
A strict concurrency check fails for a non-Combine NotificationCenter observer method.

As you can see, the compiler will tell us right away that what we’re doing is unsafe. The same failure does not arise for our Combine pipeline. In other words, our earlier Combine pipeline to observe a notification and dispatch to a @MainActor isolated method compiles successfully in Swift 6.2 and Xcode 26.

While I’ve opened an issue in the Swift Repository to ask for clarification, there’s a proper way to prevent these crashes in the first place.

Solving Actor isolation issues in Combine

Before explaining the solution, I’d like to recommend considering to migrate away from Combine pipelines if possible. In this example, we’re observing a notification. It’s perfectly doable to do this in Swift Concurrency as well:

Task { [weak self] in
    for await notification in NotificationCenter.default.notifications(named: .someNotification) {
        self?.didReceiveSomeNotification()
    }
}

You can even observe multiple notifications at once, similar to:

Publishers.Zip3(
    NotificationCenter.default.publisher(for: .someNotification),
    NotificationCenter.default.publisher(for: .notificationB),
    NotificationCenter.default.publisher(for: .notificationC)
).sink { [weak self] _ in
    self?.didReceiveSomeNotification()
}.store(in: &cancellables)

By using a custom extension from the Swift Concurrency Course:

Task { [weak self] in
    for await _ in NotificationCenter.default.notifications(named: [.someNotification, .notificationB, .notificationC]) {
        self?.didReceiveSomeNotification()
    }
}

However, you might not want to migrate all your Combine pipelines immediately. Therefore, it’s good to know that there’s another solution.

The earlier defined NotificationObserver Combine pipeline looked fine, but it does not configure any preferred thread to receive messages on. You might think using receive(on: ) will be enough to solve this issue, but it’s not. Methods like:

NotificationCenter.default.addObserver(self, selector: #selector(selectorNotificationCalled), name: .someNotification, object: nil)

And:

NotificationCenter.default.publisher(for: .someNotification)

Register the thread when being called. If you call the above methods on the main thread, notifications posted from another thread in Swift Concurrency will result in the earlier shown crash. This is simply reproducible by using the following code to post a notification:

struct DetachedNotificationPoster {
    func post() async {
        await Task.detached {
            NotificationCenter.default.post(name: .someNotification, object: nil)
        }.value
    }
}

For Combine pipelines observing notifications, I only see rewriting to the Swift Concurrency as a solution. For other pipelines, you can either use the receive(on: ) modifier to ensure you’re receiving callbacks on a matching thread, but it will only work for @MainActor isolation in combination with DispatchQueue.main. Instead, you can make use of a Task inside the sink modifier:

somePublisher
    .sink { [weak self] _ in
        Task {
            /// Do some work...
        }
    }.store(in: &cancellables)

However, once again, I decided to rewrite away from Combine as much as possible and benefit from compile-time thread safety. There are replacements for common types, such as CurrentValueSubject, and modifiers like throttle and debounce (I cover these all in my course!). It requires a mindset shift, and it may be quite some work for larger projects, but if you have the capacity, you’ll end up with a future-proof project that benefits from compile-time thread-safety checks.

Conclusion

Combine and Swift Concurrency can be used together, but can result in threading challenges. Some of these issues may not be apparent at first and might only become apparent after intensive testing. If possible, migrating away from Combine pipelines during a Swift Concurrency migration should be preferred.

If you’re ready to adopt Strict Concurrency and Swift 6, I invite you to take on my dedicated course. It’s designed to provide an in-depth understanding of concurrency and help you migrate away from Combine pipelines into Concurrency alternatives.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.