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:
// 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
:
@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
:
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
andsuccess
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:
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:
// 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
// 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
:
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:
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:
// 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:
// 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:
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:
// 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:
- New features first: Use
NotificationCenter.Message
for all new code. - Critical paths: Migrate concurrency-sensitive notifications to
MainActorMessage
orAsyncMessage
. - Interop: Implement
makeMessage
andmakeNotification
for backward compatibility. - 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.