NotificationCenter has been around forever. It’s the backbone of loose coupling in Apple platforms—post a notification here, observe it there, and things just work. Except for one problem: it never really understood Swift Concurrency.
Until now.
iOS 26 and macOS 26 introduce MainActorMessage and AsyncMessage, two protocols that finally give us type-safe, concurrency-safe notifications. If you’ve been fighting isolation warnings or casting notification.object to Any? for the hundredth time, this one’s for you.
The Problem We’ve Been Living With
Let’s start with the old way. Say you want to know when your app becomes active:
func observeAppLifecycle() {
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.refreshData()
}
}
@MainActor
func refreshData() {
// Update the UI
}
This looks fine, right? You’re observing on .main, so the closure runs on the main thread. Surely you can call a @MainActor method?
Not according to the Swift 6 compiler. You’ll get a warning about calling main-actor-isolated code from a nonisolated context. The closure’s isolation is nonisolated, regardless of what queue you pass in. The compiler can’t prove it’s safe.
You can work around it—MainActor.assumeIsolated, Task { @MainActor in ... }, whatever—but they’re all band-aids. The fundamental problem is that Notification itself carries no isolation information.
MainActorMessage: Notifications That Know Where They Belong
The new approach is refreshingly direct. Instead of Notification.Name, you observe a message type:
var observationToken: (any Sendable)?
func observeAppLifecycle() {
observationToken = NotificationCenter.default.addObserver(
of: UIApplication.self,
for: .didBecomeActive
) { [weak self] message in
self?.refreshData()
}
}
@MainActor
func refreshData() {
// No warnings here
}
That’s it. No isolation warnings, no queue parameter, no casting. The closure runs on the main actor because UIApplication.DidBecomeActiveMessage conforms to MainActorMessage.
The magic is in the protocol. MainActorMessage declares that its makeMessage(_:) factory method is @MainActor:
public protocol MainActorMessage: SendableMetatype {
associatedtype Subject
static var name: Notification.Name { get }
@MainActor static func makeMessage(_ notification: Notification) -> Self?
@MainActor static func makeNotification(_ message: Self) -> Notification
}
When you add an observer for a MainActorMessage, the observer closure inherits that isolation. The compiler knows you’re on the main actor, so it lets you call @MainActor methods directly.
Creating Your Own MainActorMessage
Apple provides message types for common notifications, but you’ll want to create your own. Here’s a practical example—notifying the app when a document finishes saving:
struct DocumentSavedMessage: NotificationCenter.MainActorMessage {
typealias Subject = Document
let document: Document
let savedAt: Date
static var name: Notification.Name {
Notification.Name("DocumentSavedMessage")
}
}
For better discoverability, add a static member:
extension NotificationCenter.MessageIdentifier
where Self == NotificationCenter.BaseMessageIdentifier<DocumentSavedMessage> {
static var documentSaved: Self { .init() }
}
Now you can observe with a clean API:
token = NotificationCenter.default.addObserver(
of: Document.self,
for: .documentSaved
) { [weak self] message in
// message.document and message.savedAt are strongly typed
self?.showSaveConfirmation(for: message.document)
}
And post just as easily:
@MainActor
func saveDocument() async throws {
try await document.save()
NotificationCenter.default.post(
DocumentSavedMessage(document: document, savedAt: Date()),
subject: document
)
}
Notice how posting is also marked @MainActor. Both sides of the notification are isolated to the main actor, and the compiler enforces it.
AsyncMessage: For When You Need Flexibility
Not every notification belongs on the main actor. Sometimes you’re posting from a background queue and don’t want to hop to main just to send a notification.
That’s where AsyncMessage comes in. It’s similar to MainActorMessage, but the observer closure is async and can run on any isolation:
struct DataSyncCompletedMessage: NotificationCenter.AsyncMessage {
typealias Subject = SyncEngine
let syncedRecordCount: Int
let duration: TimeInterval
}
extension NotificationCenter.MessageIdentifier
where Self == NotificationCenter.BaseMessageIdentifier<DataSyncCompletedMessage> {
static var dataSyncCompleted: Self { .init() }
}
Observing works the same way, but the closure is async:
token = NotificationCenter.default.addObserver(
of: SyncEngine.self,
for: .dataSyncCompleted
) { [weak self] message in
await self?.handleSyncCompletion(recordCount: message.syncedRecordCount)
}
You can post from anywhere—the main actor, a background task, wherever:
func performSync() async throws {
let start = Date()
let records = try await syncAllData()
NotificationCenter.default.post(
DataSyncCompletedMessage(
syncedRecordCount: records.count,
duration: Date().timeIntervalSince(start)
),
subject: self
)
}
The key difference: AsyncMessage notifications are delivered asynchronously. MainActorMessage notifications are delivered synchronously on the main actor. This matters if you’re relying on ordering or immediate side effects.
Goodbye, notification.object
One thing I love about the new API: strongly typed data.
Old way:
NotificationCenter.default.addObserver(
forName: .itemsUpdated,
object: nil,
queue: nil
) { notification in
guard let items = notification.object as? [Item] else { return }
// Finally, we have our items
}
New way:
token = NotificationCenter.default.addObserver(
of: ItemStore.self,
for: .itemsUpdated
) { message in
let items = message.items // Strongly typed, no casting
}
No more guard let, no more as?, no more runtime crashes when someone posts the wrong type. The compiler catches mismatches at build time.
Migration Thoughts
You don’t have to migrate everything at once. The old addObserver(forName:object:queue:using:) API still works. I’d suggest starting with notifications that already cause concurrency warnings—those are the ones where the new API will make the biggest difference.
For notifications you control (custom ones in your app), migration is straightforward. Define a message type, update the post sites, update the observers. For system notifications like didBecomeActiveNotification, check if Apple provides a message type. Most common ones have them.
One gotcha: if you’re interoperating with code that still uses the old API, you can implement both makeMessage(_:) and makeNotification(_:) to bridge between the two worlds. The message types can coexist with the legacy notification names.
Wrapping Up
NotificationCenter is old infrastructure at this point, but Apple clearly isn’t abandoning it. MainActorMessage and AsyncMessage make it a first-class citizen in the Swift Concurrency world—type-safe, isolation-aware, and much less verbose.
If you’ve already adopted Approachable Concurrency, this fits right in with that philosophy: the compiler knows more about your code, so it can help you more. And when you do need to think about isolation, the new protocols make your intent explicit.
It’s the NotificationCenter we probably should have had all along.