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:

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.
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:

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!