Black Friday 2025: Once-a-year RocketSim & SwiftLee Courses discounts. Learn more.
BF: Click for once-a-year discounts.
Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Swift Concurrency Course

MainActorMessage & AsyncMessage: Concurrency-safe notifications

Standard notifications using Notification Center are only concurrency-safe when using the MainActorMessage or AsyncMessage protocols. These protocols are available since iOS & macOS 26+ and should become the standard for using thread-safe notifications.

The Notification type is nonisolated by default, even in cases where it’s posted from known isolation domains like the main actor. To better support Swift Concurrency, Apple introduced a a new notification API. Let’s dive in!

Understanding the need for a new notifications API

Before we dive into all the details around thread-safe notifications, I would like you to understand why we need new APIs. The simplest example is observing the active state of an application. Currently, you might write that as follows:

func startObservingOldWay() {
    NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main, using: { [weak self] notification in
        self?.handleDidBecomeActive()
    })
}

@MainActor
func handleDidBecomeActive() {
    print("Did become active!")
}

Even though you’re observing for notifications on the configured .main queue, we’re still running into a Swift concurrency warning:

A Concurrency warning appears, even though observing notifications on the main queue.
A Concurrency warning appears, even though observing on the main queue.

This is because the isolation domain remains nonisolated and we’re trying to call into the handleDidBecomeActive method which is attributed to the @MainActor isolation domain.

New to actors? Check out MainActor usage in Swift explained to dispatch to the main thread.

The new notifications APIs come with built-in support for standard notifications like the above one. This means that we can rewrite our observer using the new API and solve the warning:

func startObservingNewWay() {
    token = NotificationCenter.default.addObserver(of: UIApplication.self, for: .didBecomeActive) { [weak self] message in
        self?.handleDidBecomeActive()
    }
}

@MainActor
func handleDidBecomeActive() {
    print("Did become active!")
}

The new observer method returns a message property conforming to the DidBecomeActiveMessage type:

@available(iOS 26.0, tvOS 26.0, *)
@available(watchOS, unavailable)
extension NotificationCenter.MessageIdentifier where Self == NotificationCenter.BaseMessageIdentifier<UIApplication.DidBecomeActiveMessage> {

    public static var didBecomeActive: NotificationCenter.BaseMessageIdentifier<UIApplication.DidBecomeActiveMessage> { get }
}

Which, internally, looks as follows:

public struct DidBecomeActiveMessage : NotificationCenter.MainActorMessage {

    /// A optional name corresponding to this type, used to interoperate with notification posters and observers.
    public static var name: Notification.Name { get }

    /// A type which you can optionally post and observe along with this `MainActorMessage`.
    public typealias Subject = UIApplication

    public init()

    /// Converts a posted notification into this main actor message type for any observers.
    ///
    /// To implement this method in your own `MainActorMessage` conformance, retrieve values from the ``Notification``'s ``Notification/userInfo`` and set them as properties on the message.
    /// - Parameter notification: The posted ``Notification``.
    /// - Returns: The converted `MainActorMessage` or `nil` if conversion is not possible.
    @MainActor public static func makeMessage(_ notification: Notification) -> UIApplication.DidBecomeActiveMessage?
}
FREE 5-day email course: The Swift Concurrency Playbook by Antoine van der Lee

FREE 5-Day Email Course: The Swift Concurrency Playbook

A FREE 5-day email course revealing the 5 biggest mistakes iOS developers make with with async/await that lead to App Store rejections And migration projects taking months instead of days (even if you've been writing Swift for years)

Better understanding thread-safe notifications by looking at MainActorMessage

You’re probably new to MainActorMessage, so I want you to understand how it works and how it’s used. MainActorMessage is a protocol that looks similar to what we’ve seen above for DidBecomeActiveMessage:

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
}

The main difference is made when you start observing a notification message that conforms to this protocol. The protocol ensures that the observation closure gets called on the main actor due to the @MainActor attribute:

The observer closure will be called on the main actor due to the MainActorMessage protocol.
The observer closure will be called on the main actor due to the MainActorMessage protocol.

This is also why the compiler can correctly reason and ensure thread safety when calling into our handleDidBecomeActive method.

Delivered synchronously

It’s interesting to look at the posting side of MainActorMessage notifications:

/// Posts a given main actor message to the notification center.
/// - Parameters:
///   - message: The message to post.
///   - subject: The subject instance that corresponds to the message.
@MainActor public func post<Message>(_ message: Message, subject: Message.Subject) where Message : NotificationCenter.MainActorMessage, Message.Subject : AnyObject

As you can see, the method needs to be called from the main actor. Combine this with the synchronous observer closure of the addObserver method, and we can conclude that notifications are delivered synchronously.

In the old way, notifications got delivered on the same queue from which they were posted. Now that @MainActor is more strongly enforced by the API, your app’s threading behavior may change after migration.

How about AsyncMessage?

Now that we understand how the @MainActor works with thread-safe notifications, it’s time to look at AsyncMessage. You will use this type of message when you want to deliver the notification asynchronously on an arbitrary isolation.

Since we’re dealing with arbitrary isolation, we will also need to handle asynchronous contexts. The observer method returns an async-marked, @Sendable closure. Posting an AsyncMessage can be done from any isolation domain.

Creating a custom AsyncMessage

To put our learnings into practice, I’d love to show an example of a custom AsyncMessage. The implementation of this example is pretty much equal to using MainActorMessage.

In this case, I’m migrating a notification from RocketSim:

extension Notification.Name {
    /// Posted when the recent builds for the current Simulator changed.
    /// - Contains the new recent builds array as an object.
    static let recentBuildsChanged = Notification.Name(rawValue: "recent.builds.changed")
}

It’s interesting to point out that I’ve used the documentation to explain the contents of the notification. A big downside of the old API is that we’re relying on a notification.object of type Any. We have no strongly typed notifications!

The migration we’re about to do will not only introduce strongly typed notifications, but also thread-safe ones. Pretty neat. Let’s dive into the code.

First, we’re creating our custom message type:

struct RecentBuildsChangedMessage: NotificationCenter.AsyncMessage {
    typealias Subject = [RecentBuild]
    
    let recentBuilds: Subject
}

Followed by improving discoverability using Static Member Lookup in Generic Contexts:

extension NotificationCenter.MessageIdentifier where Self == NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> {

    static var recentBuildsChanged: NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> {
        .init()
    }
}

After that, we update the code that posts the notification:

let recentBuilds = [RecentBuild(appName: "Stock Analyzer")]
let message = RecentBuildsChangedMessage(recentBuilds: recentBuilds)
NotificationCenter.default.post(message)

Updating the observation logic

Our new code becomes extra valuable when we look at the old observation logic:

NotificationCenter.default.addObserver(forName: .recentBuildsChanged, object: nil, queue: nil) { [weak self] notification in
    guard let recentBuilds = notification.object as? [RecentBuild] else { return }
    self?.handleNewRecentBuilds(recentBuilds)
}

As you can see, we first need to unwrap the notification.object before we can continue. Now, have a look at the refactored observation logic:

recentBuildsToken = NotificationCenter.default.addObserver(of: [RecentBuild].self, for: .recentBuildsChanged) { [weak self] message in
    self?.handleNewRecentBuilds(message.recentBuilds)
}

Isn’t that great? We can simply rely on the new recentBuilds property and access our recent builds directly. We end up with thread-safe notifications and less boilerplate code.

Conclusion

Thread-safe notifications using MainActorMessage and AsyncMessage allow us to inform about the isolation context. Using knowledge of the isolation domain, the compiler can warn us about thread safety at compile time.

Swift Concurrency is much more than just notifications, so I’d love to invite you to check out www.swiftconcurrencycourse.com for an in-depth course on Swift 6.2 & Swift Concurrency.

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.