NotificationCenter.Message: A New Concurrency-Safe Notification Experience in Swift 6.2

Published on

NotificationCenter has long been a staple of iOS development, offering developers a flexible broadcast–subscribe mechanism. However, as Swift’s concurrency model has advanced, the traditional approach—using string-based identifiers and a userInfo dictionary—has revealed several pitfalls: thread-safety hazards, silent typos, and unsafe type casts. These issues often only surface at runtime.

To eliminate these pain points, Swift 6.2 introduces a brand-new, concurrency-safe notification protocols in Foundation: NotificationCenter.MainActorMessage and NotificationCenter.AsyncMessage. Leveraging Swift’s type system and concurrency isolation, it validates both posting and observing at compile time, completely eradicating common problems like “wrong thread” or “payload type mismatch.”

The Limits of Traditional Notification

Let’s start with a quick review of how traditional Notification APIs are used:

Swift
// Post a notification
NotificationCenter.default.post(
    name: .userDidLogin,
    object: authManager,
    userInfo: ["userID": user.id]
)

// Observe a notification
NotificationCenter.default.addObserver(
    forName: .userDidLogin,
    object: authManager,
    queue: .main
) { notification in
    guard let id = notification.userInfo?["userID"] as? String else {
        return
    }
    print("User logged in:", id)
}

Despite its simplicity, this approach has some critical flaws:

  • Error-prone string identifiers: Typos are only caught at runtime, increasing debugging cost.
  • Lack of type safety: Manual casting from userInfo is fragile and easy to misuse.
  • Unclear threading behavior: Callbacks may execute on unspecified threads, leading to race conditions.
  • No compile-time checks: Mistakes are often only exposed during execution.

These issues become especially serious when used alongside Swift concurrency features like @ModelActor, where mismanaged thread context can cause app crashes.

NotificationCenter.Message: A Type-Safe Solution

o tackle these problems, the Swift community proposed “Concurrency-Safe Notifications” in late 2024. This was officially introduced in Swift 6.2.

The final design introduces two core message protocols in NotificationCenter:

Swift
@available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, *)
extension NotificationCenter {
    public protocol MainActorMessage: SendableMetatype {
        associatedtype Subject
        static var name: Notification.Name { get }
        static func makeMessage(_ notification: Notification) -> Self?
        static func makeNotification(_ message: Self, object: Subject?) -> Notification
    }

    public protocol AsyncMessage: Sendable {
        associatedtype Subject
        static var name: Notification.Name { get }
        static func makeMessage(_ notification: Notification) -> Self?
        static func makeNotification(_ message: Self, object: Subject?) -> Notification
    }
}

These represent two distinct message types:

  • MainActorMessage: Tied to the main thread, guaranteeing synchronous execution.
  • AsyncMessage: Sendable and safe to transmit across concurrency domains.

How to Use It

Define a Message Type

Start by creating a message type conforming to MainActorMessage:

Swift
public class DownloadManager {
    static let shared = DownloadManager()
}

public struct DownloadDidFinish: MainActorMessage {
    public typealias Subject = DownloadManager  // Matches the `object` in post()

    public static var name: Notification.Name {
        .init("DownloadManager.DownloadDidFinish")
    }

    // Strongly typed payload replacing userInfo
    public let fileURL: URL    // old userInfo["fileURL"]
    public let success: Bool   // old userInfo["success"]
}

Here:

  • Subject represents the sender.
  • fileURL and success are type-safe message fields.
  • name is preserved for compatibility (optional if using only new APIs).

(Optional) Define a MessageIdentifier

For a more ergonomic API, define an identifier:

Swift
extension NotificationCenter.MessageIdentifier
where Self == NotificationCenter.BaseMessageIdentifier<DownloadDidFinish> {
    static var downloadDidFinish: Self { .init() }
}

This lets you subscribe with .downloadDidFinish instead of the full type.

Post a Message

Two options:

Swift
// Associated with a specific instance
NotificationCenter.default.post(
    DownloadDidFinish(fileURL: url, success: true),
    subject: DownloadManager.shared
)

// Global broadcast (object == nil)
NotificationCenter.default.post(
    DownloadDidFinish(fileURL: url, success: false)
)

Observe a Message

Synchronous Callback

Swift
// Only for a specific instance
let token = NotificationCenter.default.addObserver(
    of: DownloadManager.shared,
    for: .downloadDidFinish
) { message in
    print("Download finished—success:", message.success)
}

// Respond to all messages of the type
let globalToken = NotificationCenter.default.addObserver(
    for: DownloadDidFinish.self
) { message in
    print("File:", message.fileURL, "success:", message.success)
}

Asynchronous Stream

For AsyncMessage, you can use Swift’s AsyncSequence:

Swift
struct DownloadFinishAsyncMessage: NotificationCenter.AsyncMessage {
    typealias Subject = DownloadManager
    let fileURL: URL
    let success: Bool
}

// Using AsyncSequence
Task {
    for await msg in NotificationCenter.default.messages(
        of: DownloadManager.shared,
        for: DownloadFinishAsyncMessage.self
    ) {
        print("Async download complete:", msg.fileURL)
    }
}

Seamless Integration with Legacy API

To migrate progressively or support third-party code, implement conversion methods:

Swift
extension DownloadDidFinish {
    // Convert legacy Notification to Message
		public static func makeMessage(_ notification: Notification) -> DownloadDidFinish? {
        guard
            let info = notification.userInfo,
            let url = info["fileURL"] as? URL,
            let ok = info["success"] as? Bool
        else {
            return nil
        }
        return Self(fileURL: url, success: ok)
    }

		// Convert Message to legacy Notification
    public static func makeNotification(_ message: DownloadDidFinish, object: DownloadManager?) -> Notification {
        Notification(
            name: name,
            object: object,
            userInfo: [
                "fileURL": message.fileURL,
                "success": message.success
            ]
        )
    }
}

With these conversion methods, new and legacy APIs can interoperate seamlessly:

Swift
// Send via legacy API
NotificationCenter.default.post(
    name: .init("DownloadManager.DownloadDidFinish"),
    object: nil,
    userInfo: [
        "fileURL": URL(string: "https://example.com/file.zip")!,
        "success": false
    ]
)
// New-style subscribers will receive the DownloadDidFinish message via conversion.

Similarly, you can observe new messages using the traditional approach:

Swift
// Observe using legacy Combine publisher
.onReceive(
    NotificationCenter.default
        .publisher(for: DownloadDidFinish.name)
) { notification in
    guard let userInfo = notification.userInfo,
          let success = userInfo["success"] as? Bool
    else {
        return
    }
    print("Success:", success)
}

Integrating with SwiftUI

While SwiftUI doesn’t yet support this feature natively, you can create custom view modifiers:

Swift
struct MessageIdentifierObserverModifier<ID>: ViewModifier
    where ID: NotificationCenter.MessageIdentifier,
    ID.MessageType: NotificationCenter.MainActorMessage,
    ID.MessageType.Subject: AnyObject
{
    let messageID: ID
    let subject: ID.MessageType.Subject?
    let perform: (ID.MessageType) -> Void
    @State var token: NotificationCenter.ObservationToken?
    init(
        messageID: ID,
        subject: ID.MessageType.Subject?,
        perform: @escaping (ID.MessageType) -> Void)
    {
        self.messageID = messageID
        self.subject = subject
        self.perform = perform
    }

    func body(content: Content) -> some View {
        content
            .onAppear {
                guard token == nil else { return }
                if let subject {
                    token = NotificationCenter.default.addObserver(
                        of: subject,
                        for: messageID
                    ) { perform($0)
                    }
                } else {
                    token = NotificationCenter.default.addObserver(
                        of: subject,
                        for: ID.MessageType.self)
                    { perform($0)
                    }
                }
            }
            .onDisappear {
                if let t = token {
                    NotificationCenter.default.removeObserver(t)
                }
            }
    }
}

struct MessageTypeObserverModifier<Message>: ViewModifier
    where Message: NotificationCenter.MainActorMessage,
    Message.Subject: AnyObject
{
    let messageType: Message.Type
    let subject: Message.Subject?
    let perform: (Message) -> Void

    @State private var token: NotificationCenter.ObservationToken?

    init(
        messageType: Message.Type,
        subject: Message.Subject? = nil,
        perform: @escaping (Message) -> Void)
    {
        self.messageType = messageType
        self.subject = subject
        self.perform = perform
    }

    func body(content: Content) -> some View {
        content
            .onAppear {
                guard token == nil else { return }
                token = NotificationCenter.default.addObserver(
                    of: subject,
                    for: messageType,
                    using: perform)
            }
            .onDisappear {
                if let t = token {
                    NotificationCenter.default.removeObserver(t)
                    token = nil
                }
            }
    }
}

extension View {
    func onReceive<Message>(
        of messageType: Message.Type,
        subject: Message.Subject? = nil,
        perform: @escaping (Message) -> Void) -> some View
        where Message: NotificationCenter.MainActorMessage,
        Message.Subject: AnyObject
    {
        modifier(
            MessageTypeObserverModifier(
                messageType: messageType,
                subject: subject,
                perform: perform))
    }
}

extension View {
    func onReceive<ID>(
        for messageID: ID,
        subject: ID.MessageType.Subject? = nil,
        perform: @escaping (ID.MessageType) -> Void) -> some View
        where
        ID: NotificationCenter.MessageIdentifier,
        ID.MessageType: NotificationCenter.MainActorMessage,
        ID.MessageType.Subject: AnyObject
    {
        modifier(MessageIdentifierObserverModifier(
            messageID: messageID,
            subject: subject,
            perform: perform))
    }
}

You can now elegantly subscribe to MainActorMessage in SwiftUI:

Swift
// For specific subject
.onReceive(for: .downloadDidFinish, subject: DownloadManager.shared) { message in
    print(message.success)
}
// For all messages
.onReceive(of: DownloadDidFinish.self) { message in
    print(message.success)
}

Migration Recommendations

For existing projects, consider a gradual migration:

  1. New features first: Use NotificationCenter.Message for all new code.
  2. Critical paths: Migrate concurrency-sensitive notifications to MainActorMessage or AsyncMessage.
  3. Interop: Implement makeMessage and makeNotification for backward compatibility.
  4. Replace gradually: Swap out old post(name:) / addObserver(forName:) calls over time.

Summary

NotificationCenter.Message brings the long-awaited type and concurrency safety to Swift’s notification system. With compile-time checks and explicit isolation semantics, it enhances both reliability and developer experience.

If you’re targeting Swift 6.2 on iOS 26 or macOS 26, give concurrency-safe notifications a try—and enjoy fewer runtime crashes and more compile-time confidence.

If this article helped you, feel free to buy me a coffee ☕️ . For sponsorship inquiries, please check out the details here.

Weekly Swift & SwiftUI highlights!