A deep dive into notifications and messages on iOS 26
Table of contents

NotificationCenter
(opens in a new tab) enables loose coupling across codebases using Apple’s Foundation framework, such as iOS apps. One part of a project can post a notification when an event occurs, and any other part can react by listening for/observing that notification.
As proposed in SF-0011 Concurrency-Safe Notifications(opens in a new tab), iOS 26 introduces new message APIs as an alternative to NotificationCenter
. These APIs improve both type safety and concurrency safety, allowing the Swift compiler to better verify code correctness.
This article reviews the range of NotificationCenter
APIs, discussing their pros and cons. It then compares them with the new message APIs and concludes with guidance on bridging between messages and the older notification APIs, highlighting key nuances of the new system.
Notification APIs
There’s one way to post notifications, and there are several ways to observe them. These APIs are all based around Notification
(opens in a new tab).
Notification
is the Swift struct
overlay on the Objective-C NSNotification
(opens in a new tab) class. Since it’s fairly uncommon to copy or mutate notifications, this difference isn’t practically important.
A notification consists of three components:
- The name is a
String
that identifies the type of event that occurred. - The object can be any type. It’s typically the object that can be thought of as sending the notifications, but this isn’t enforced.
- The ‘user info’ dictionary can contain objects of any type that might be useful to know about the event that occurred.
In typical use of Notification
, the name implies the object will be of a certain type, and the user info dictionary will contain objects of certain types under certain keys. The compiler can’t verify this, so as a codebase grows in complexity, documentation and tests are your best bet to ensure all notifications are posted and received with consistent type expectations.
For completeness, the API for posting notifications(opens in a new tab) looks like this:
NotificationCenter.default.post(name: .XYZTrainDidArrive, object: expressTrain, userInfo: [trainDelayInMinsUserInfoKey: 13])
If you like, you can explicitly create a Notification
with the same values, but I can’t think of many practical situations where this would be beneficial:
NotificationCenter.default.post(Notification(name: .XYZTrainDidArrive, object: expressTrain, userInfo: [trainDelayInMinsUserInfoKey: 13]))
The code above adds a prefix to the name to avoid potential conflicts, since it’s declared in an extension of Notification.Name
, a namespace we don’t control. The same applies to the underlying string value. While omitting the prefix is usually safe in app code, it’s important when developing a framework like ours.
Selector-based observation API
The oldest approach(opens in a new tab) relies on the Objective-C runtime and separates observation setup from handling:
@objc func handleArrivingTrain(_ notification: Notification) { // Handle the notification.}
Start the observation:
NotificationCenter.default.addObserver(self, selector: #selector(handleArrivingTrain), name: .XYZTrainDidArrive, object: expressTrain)
Stop the observation like this, for example, after a view disappears:
NotificationCenter.default.removeObserver(self, name: .XYZTrainDidArrive, object: expressTrain)
A theoretical downside of this API is that if multiple places in the project set up distinct observations with the same observer, name, and object, then that single call to removeObserver
would stop all these observations, with no way to make the removal more specific. I’ve never seen this be problematic in practice.
If you can’t stop the observation from an appropriate place or (more likely) forget to do this, nothing bad will typically happen, because if the observing object is destroyed, the notification centre detects this and stops posting to that observer. This works by the notification centre keeping a weak reference to the observer, so there’s no opportunity to introduce retain cycles with this API.
The use of the Objective-C runtime means the Swift compiler can’t check this for concurrency safety, so therefore won’t produce any concurrency warnings or errors. This is likely to be fine most of the time, but not always.
While this API is the oldest, I think it’s still a very practical option, especially with UI code that all runs on the main thread.
Closure-based observation API
The closure-based API(opens in a new tab) keeps setup and handling together, which may improve code readability:
let trainObservationToken = NotificationCenter.default.addObserver(forName: .XYZTrainDidArrive, object: expressTrain, queue: nil) { [weak self] notification in // Handle the notification.}
Since this API is imported from Objective-C, the return value can implicitly be ignored. However, behind the scenes, NotificationCenter
strongly retains the returned token until removeObserver
is called.
This means that if you don’t stop the observation, you’ll have a small memory leak of the observation token and an ‘execution leak’, in that the closure will keep running whenever this notification is posted for the entire remaining lifetime of the process. If the closure captures additional objects, you may have a larger memory leak. Observations are stopped like this:
NotificationCenter.default.removeObserver(trainObservationToken)
To reiterate: To use this API properly, you need to both store the token (probably in a property) and call removeObserver
later on.
This API doesn’t work well with strict concurrency either. Even if you explicitly specify the main queue, the compiler sees the closure as running synchronously and without actor isolation. Therefore, it’s hard to get code using this to compile when using Swift 6 language mode. You can use assumeIsolated
, but then you’ll have trouble if you need the notification because it’s not sendable, because it could contain anything. For notifications related to UI, nothing is ever leaving the main actor, but the compiler doesn’t know this.
The closure-based API is the notification API I see used incorrectly most often. I recommend avoiding it.
Combine observation API
You can observe notifications using Combine(opens in a new tab). Here’s the most basic situation:
self.trainObservation = NotificationCenter.default.publisher(for: .XYZTrainDidArrive, object: localTrain).sink { [weak self] notification in // Handle the notification.}
And then:
self.trainObservation.cancel()
This is basically a better version of the closure-based API:
- There are slightly fewer characters in the most basic case.
- You’ll see a warning if you don’t use the return value.
- This won’t do anything if you don’t store the return value, since the observations will stop as soon as it’s created. This is good, because you’ll likely notice the mistake as soon as you run your code.
- The observation will automatically stop if you store it in a property and the owning object is destroyed, because the observation will be destroyed too.
- There’s a lot of flexibility with Combine — for example, you can conveniently filter or map the stream of notifications.
This approach doesn’t produce any strict concurrency warnings or errors, seemingly because the closure passed to sink
isn’t marked as @Sendable
. My understanding is that this closure may in fact be sent across actor boundaries, so this approach is similar to the selector-based API with regards to strict concurrency: no warnings and no guarantees about safety, which is probably fine in a lot of cases.
Async sequence API
The newest notification observation API(opens in a new tab) leverages AsyncSequence
(opens in a new tab). You can set up the observation like this:
let trainObservationTask = Task { for await notification in NotificationCenter.default.notifications(named: .XYZTrainDidArrive, object: localTrain) { // Handle the notification. } // Execution won’t reach here until the task is cancelled.}
This has similar behaviour to the closure-based API: You can implicitly ignore the created task, and unless it’s cancelled, the observation will continue (in other words, the code will await
the end of the sequence) for the entire remaining lifetime of the process. The observation can be stopped by cancelling the Task
or a parent Task
:
trainObservationTask.cancel()
The documentation says that iterating over the Notifications
will result in a warning about crossing an actor boundary, and therefore we should use map
to extract only Sendable
data from each notification and then iterate over that. However, in my test project, I didn’t see this warning in Swift 5 or 6 language modes.
We haven’t found a situation where this API seems like the best choice in our codebase, but it’s an interesting option. This API doesn’t fit well for notifications that should be posted and observed on the main thread, but it may be a good option if you’re heavily using concurrency.
Message APIs
iOS 26 adds new APIs for both posting and observing messages. There are two new types for different concurrency scenarios: NotificationCenter.MainActorMessage
(opens in a new tab) and NotificationCenter.AsyncMessage
(opens in a new tab). We’ll just look at MainActorMessage
in this article.
These types are both protocols, moving away from the Notification
struct entirely. I guess Apple chose to not reuse the term notification to make the distinction clearer. In a couple of years, perhaps new iOS developers will be puzzled as to why it’s called NotificationCenter
rather than MessageCenter
. Or, maybe they won’t, because their AI agents will handle details like this.
The simplest definition of a message doesn’t interoperate with Notification
at all:
struct TrainDidArriveMessage: NotificationCenter.MainActorMessage { typealias Subject = Train}
Our type in this simple example has no properties. This is the equivalent of a Notification
without anything in userInfo
. We’ll add back the equivalent of trainDelayInMinsUserInfoKey
shortly.
The equivalent of the object (the sender) with Notification
is now called the subject, which is a Train
in this example. We can post a message like this:
NotificationCenter.default.post(TrainDidArriveMessage(), subject: expressTrain)
And we can observe the message:
self.trainObservationToken = NotificationCenter.default.addObserver(of: expressTrain, for: TrainDidArriveMessage.self) { [weak self] message in // Handle the message.}
This new API works excellently with Swift strict concurrency. Since we used MainActorMessage
in this example, our closure that handles the message is isolated to the main actor.
We also have compile-time guarantees about message structure. If the message has some properties, there’s of course no need to force unwrap or force cast when reading these (like there would be with userInfo
). What a luxury!
Similar to the Combine API, the observation will be stopped if the observation token isn’t kept alive, which encourages correct use to avoid leaking the observation. We can stop observing earlier like this:
NotificationCenter.default.removeObserver(self.trainObservationToken)
This looks the same as the older notification API to stop observing, but the type of the parameter is NotificationCenter.ObservationToken
(opens in a new tab) instead of Any
.
API design considerations
It’s nice to have message types nested under their subject type. For example, instead of using a module-level TrainDidArriveMessage
, we could do this:
extension Train { struct DidArriveMessage: NotificationCenter.MainActorMessage { typealias Subject = Train }}
Apple includes the word Message
at the end of its message types — for example, UIScene.WillConnectMessage
. I did the same in my first pass adding messages in Nutrient iOS SDK, but then (before seeing Apple’s API) thought the usage would be nicer and the meaning still clear without this word, so we’d just have Train.DidArrive
as the type of the message. (Our framework is about document viewing and editing rather than trains, but I’ll keep my example consistent.) I’d love to know Apple’s reasoning behind its naming decision.
For frameworks, it’s a common situation that the framework defines a message for public observation that should only be posted internally by the framework. Correct API usage can be enforced by not making any public initialisers of the message type. Interestingly, Apple has made its initialisers public so, for example, we could create and post our own UIScene.WillConnectMessage
, although I can’t see why this would be useful. Again, I’d really like to know the reasoning behind this, as I’m sure adding public APIs is not something done lightly at Apple’s scale.
Message identifiers
We have the option of using NotificationCenter.MessageIdentifier
(opens in a new tab) to make setting up each observation slightly more streamlined as the cost of more boilerplate for each message type. The identifier is set up with this generic boilerplate mess that you should probably copy and paste rather than learn or understand:
extension NotificationCenter.MessageIdentifier where Self == NotificationCenter.BaseMessageIdentifier<Train.DidArriveMessage> { static var didArrive: Self { .init() }}
This turns Train.DidArriveMessage.self
into just .didArrive
when setting up an observation, which is nice, but not a huge reduction:
let observationToken = NotificationCenter.default.addObserver(of: expressTrain, for: .didArrive) { [weak self] message in // Handle the message.}
The identifier can simply be .didArrive
without the risk of conflict identifiers, because this extension is only valid where the subject type is Train
to match the first parameter. For example, this won’t conflict if you have a Bus
that also has a message with identifier .didArrive
. At least with Xcode 26 beta 4, this type constraint isn’t recognised by auto-completion, which will suggest all possible messages from Apple’s frameworks as alternatives to .didArrive
, even if the resulting code won’t compile.
The overhead of defining identifiers might not be worthwhile for notifications used in a single module, but it make more sense for frameworks — especially frameworks with public APIs like ours, where messages might be observed in many apps.
Bridging between messages and notifications
So that we don’t have to update all code at once, the messages API can interoperate in either direction with existing Notification
code. This means a message can be posted and observed as a notification, and vice versa. This is especially useful for frameworks like ours, since our customers might be faster or slower than us at adopting the newer APIs.
Messages without properties
If the message has no properties (corresponding to no userInfo
), we can bridge to and from Notification
by specifying the Subject
type and implementing name
and makeMessage
:
extension Train { struct DidArriveMessage: NotificationCenter.MainActorMessage { typealias Subject = Train
public static var name: Notification.Name { .XYZTrainDidArrive }
public static func makeMessage(_ notification: Notification) -> Self? { .init() } }}
The default implementation of makeNotification
is sufficient in this simple case.
You should always implement name
and makeMessage
together. If you only implement name
, then this error will be logged when attempting to post a notification and observe it as a message with a matching name:
Unable to deliver Notification to Message observer because TrainDidArriveMessage.makeMessage() returned nil. If this is unexpected, check or provide an implementation of makeMessage() which returns a non-nil value for this notification’s payload.
The process won‘t crash; the message simply won’t be received. This sort of near-silent failure won’t be fun to diagnose in production, so don’t do that to yourself.
Messages with properties
If our messages/notifications include properties/userInfo
such as information about how much the arriving train is delayed, they’d look like this:
let trainDelayInMinsUserInfoKey = "XYZDelayInMins"
extension Train { struct DidArriveMessage: NotificationCenter.MainActorMessage { let delayInMins: Int
typealias Subject = Train
public static var name: Notification.Name { .XYZTrainDidArrive }
public static func makeMessage(_ notification: Notification) -> Self? { .init(delayInMins: notification.userInfo![trainDelayInMinsUserInfoKey] as! Int) }
public static func makeNotification(_ message: Self, object: Train?) -> Notification { Notification(name: name, object: object, userInfo: [trainDelayInMinsUserInfoKey: message.delayInMins]) } }}
We’ve added makeNotification
, since the default implementation is no longer sufficient.
Of course, bridging from an API without compiler checks to one with compiler checks, you’ll end up with some unwraps and casts. These go in makeMessage
. Since my goal is to write correct code in the long term rather than code that doesn’t crash, I think force unwrapping and casting is the fastest way to validate that the notification code that the compiler can’t check is in fact correct. If you return nil
in makeMessage
, then this programmer error will show only as a logged runtime warning.
The subject is unavailable when observing
With the notification observation API, from what I’ve seen, it’s fairly common to use nil
as the object to observe notifications from any object, and then filter in a way that’s more complex than just object equality.
For example, suppose you wanted to handle all trains arriving from a certain operator. With the notification API, you could set up your observation omitting the object (equivalent to passing nil
) and then do your custom filtering:
let trainObservation = NotificationCenter.default.publisher(for: .XYZTrainDidArrive) .filter { notification in (notification.object as! Train?)?.operator == someTrainOperator } .sink { [weak self] notification in // Handle the notification for trains from a specific operator. }
However, the subject isn’t passed into our closure. Here, we use Train.self
to indicate we want to receive messages about any train arriving:
self.trainObservationToken = NotificationCenter.default.addObserver(of: Train.self, for: .didArrive) { [weak self] message in // No access to the subject (the specific train) here.}
This issue is discussed at the bottom of the SF-0011 proposal(opens in a new tab) stating the reason for not including this was:
the
addObserver()
closure would always have to specify a subject parameter even for messages without subject instances.
The reasoning seems to be that because this parameter would only sometimes be useful, it’s better to not provide it at all. I don’t understand this conclusion.
To work around this limitation, we could make the subject part of the message:
extension Train { struct DidArriveMessage: NotificationCenter.MainActorMessage { typealias Subject = Train
let train: Train }}
But then a message could be posted with an inconsistent subject like this:
NotificationCenter.default.post(TrainDidArriveMessage(train: expressTrain), subject: localTrain)
This is a limitation of the current API, so I’m not sure what best practice will evolve here.
Conclusion
Aside from the subject being unavailable when observing, this messages API seems very nicely done. Compiler checks for type- and concurrency-safety are a huge improvement, so this is the clear API to use once you can require iOS 26 as your minimum version.
In our frameworks, we intend to provide equivalent APIs for both messages and notifications so our customers can choose whichever works best for them. While interoperability results in a lot of boring bridging code, this means our customers can start using messages without us holding them back. Once we require iOS 26 in a couple of years, we’ll surely be using messages internally — while our customers can choose to stick with notifications if they prefer.
Additional reading
- Original Swift Foundation proposal(opens in a new tab) from 5 Aug 2024
- Final state of the proposal: SF-0011: Concurrency-Safe Notifications(opens in a new tab)
NotificationCenter.MainActorMessage
documentation(opens in a new tab)NotificationCenter.AsyncMessage
documentation(opens in a new tab)- NotificationCenter.Message: A New Concurrency-Safe Notification Experience in Swift 6.2(opens in a new tab) by Fatbobman