Apple Watch Notification Center timestamp drift on notifications processed by a Notification Service Extension

I'm seeing a timestamp display issue on Apple Watch Notification Center, and I'd like to confirm whether this is a known watchOS behavior or whether there's a setup mistake on our side.

Symptom

The same APNs notification displays the correct time on iPhone Notification Center and on the initial Apple Watch banner. After the Watch screen turns off and the user later opens Notification Center on Apple Watch, the same notification may show an incorrect relative timestamp such as "3 hours ago" or even "yesterday". The drift is per-notification and persists for that notification until it's dismissed.

iPhone NC always shows the correct time. WhatsApp tested side-by-side on the same iPhone/Watch pair does not show this drift.

Setup

  • iOS 26.4.2 on iPhone 16 Pro
  • watchOS 26.4 on Apple Watch Series 10
  • App with paired Apple Watch
  • A UNNotificationServiceExtension that decrypts E2EE message previews and applies Communication Notification enrichment via INSendMessageIntent and content.updating(from:)
  • Production APNs environment, TestFlight builds
  • No beta software

Isolation tests already performed

Testmutable-contentNSE invokedDrift on Watch NC
Minimal APNs (alert + sound only)nonono drift
NSE skips content.updating(from:), INInteraction.donate/delete (still calls them as no-op via diagnostic build)yesyes — modifies contentdrift
NSE bypasses ALL Intents/Communication Notification APIs (no INPerson, no INSendMessageIntent, no avatar, no updating(from:)); just modifies title/body/sound/category and returns the mutable copyyesyes — modifies contentdrift
Production-like APNs payload (thread-id, target-content-id, category, sound, badge, custom userInfo) but WITHOUT mutable-contentnonono drift

Eliminated as causes: content.updating(from:), INSendMessageIntent, INInteraction.donate, INInteraction.delete(with:), INPerson/INPersonHandle (not even constructed in test 3), avatar fetching, thread-id, target-content-id, category, sound, badge, custom userInfo, custom createdAt timestamp, stale Siri/Apple Intelligence history (cleared manually on iPhone and Watch).

The pattern

The only consistent variable distinguishing the no-drift cases from the drift cases is whether mutable-content: 1 is set on the APNs payload (i.e. whether the UNNotificationServiceExtension is invoked). Once invoked, the extension's behavior with respect to Communication Notifications does not seem to affect the outcome — the drift reproduces even when the NSE only modifies title/body/sound and returns.

Questions

  1. Is there a known watchOS behavior where notifications processed by a UNNotificationServiceExtension use a different timestamp source on Apple Watch Notification Center after the Watch screen has been turned off and reopened, while the initial Watch banner and iPhone Notification Center show the correct delivery time?
  2. Are there specific UNMutableNotificationContent properties or APNs payload flags that should be preserved (or avoided) when returning content from an NSE to keep the Watch NC timestamp consistent with the delivery time?
  3. For E2EE messaging apps, is there a recommended pattern to decrypt and return content from an NSE that avoids this drift on watchOS?

Happy to provide an anonymized snippet of NotificationService.swift and the APNs payload format if useful.

Thanks.

Answered by DTS Engineer in 887950022

I'm seeing a timestamp display issue on Apple Watch Notification Center, and I'd like to confirm whether this is a known watchOS behavior or whether there's a setup mistake on our side.

I'm not aware of any issue like this, and the general behavior you’re describing sounds like a bug. Have you filed a bug on this and, if so, what's the bug number? Also, what system versions are you seeing this on and have you seen it on watchOS 26.5?

For E2EE messaging apps, is there a recommended pattern to decrypt and return content from an NSE that avoids this drift on watchOS?

In theory, what you're doing should "just work". More specifically, by design, your extension doesn't control the time data of the notification you're returning, so what actually happens is that the UNNotificationContent your extension returned is immediately embedded into a newly created UNNotification object, which is then processed "normally". How that would end up creating the situation you're describing is something I can't explain.

However, this is interesting:

WhatsApp tested side-by-side on the same iPhone/Watch pair does not show this drift.

I might actually have an explanation for this. I don't know if their app is doing this, but there's another way an NSE can get notifications to "the screen" beyond just posting modified content.

It's not necessarily well known, but an NSE can directly post local notifications (or delete), instead of just returning modified content. This is a very useful technique in messaging apps, as it lets you "backfill" missed messages and/or unrelated content. Putting that in more concrete terms, you can do things like this:

  1. Your NSE receives a new notification.

  2. Your NSE connects to the server, notices that it's missed 3 messages on the "current" thread and 2 messages on unrelated threads, and downloads all of the content for those other messages.

  3. Your NSE posts 5 different local notifications for the missed notifications, "filling out" the content of those threads.

  4. Your NSE then returns the content of the notification it actually received.

Any app can use the flow above; however, if your app has the filtering entitlement, then step #4 is optional, so you could just discard all of the "push" notifications and instead route everything through your local notification logic.

More to the point, there's a good argument that doing so is actually a BETTER architectural choice than going to the trouble of doing that "extra" push at the end. The issue here is that under real-world conditions, the range of possibilities quickly becomes fairly unwieldy and fairly weird.

For example, it's entirely possible that latency issues mean that the message data (embedded in the push) you're currently looking at is actually OLDER than the message data you end up getting from the server. You could "mask" that by shuffling the notifications you push such that the push you received is actually posted as a local notification and the most recent is sent as the NSE content; however:

  • That's a weird thing to try and logically think through in code.

  • It's entirely possible that you’ll shortly receive a new push for that most recent push, at which point you'll need to either discard THAT push or "fiddle" with notification content so you can repost the same notification again.

While it's certainly possible to make all of that work, it's actually somewhat easier to just filter "all" notifications and then have a "notification engine" which takes whatever data your NSE has/downloads and then posts everything it needs to through the local notification system. That way, there's only a single code path for every notification, and you don't have to worry about issues like the timing difference between your NSE returning data and local notification reporting.

In any case, it’s entirely possible that they’re doing what I’ve described above, which might explain the difference in behavior.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Accepted Answer

I'm seeing a timestamp display issue on Apple Watch Notification Center, and I'd like to confirm whether this is a known watchOS behavior or whether there's a setup mistake on our side.

I'm not aware of any issue like this, and the general behavior you’re describing sounds like a bug. Have you filed a bug on this and, if so, what's the bug number? Also, what system versions are you seeing this on and have you seen it on watchOS 26.5?

For E2EE messaging apps, is there a recommended pattern to decrypt and return content from an NSE that avoids this drift on watchOS?

In theory, what you're doing should "just work". More specifically, by design, your extension doesn't control the time data of the notification you're returning, so what actually happens is that the UNNotificationContent your extension returned is immediately embedded into a newly created UNNotification object, which is then processed "normally". How that would end up creating the situation you're describing is something I can't explain.

However, this is interesting:

WhatsApp tested side-by-side on the same iPhone/Watch pair does not show this drift.

I might actually have an explanation for this. I don't know if their app is doing this, but there's another way an NSE can get notifications to "the screen" beyond just posting modified content.

It's not necessarily well known, but an NSE can directly post local notifications (or delete), instead of just returning modified content. This is a very useful technique in messaging apps, as it lets you "backfill" missed messages and/or unrelated content. Putting that in more concrete terms, you can do things like this:

  1. Your NSE receives a new notification.

  2. Your NSE connects to the server, notices that it's missed 3 messages on the "current" thread and 2 messages on unrelated threads, and downloads all of the content for those other messages.

  3. Your NSE posts 5 different local notifications for the missed notifications, "filling out" the content of those threads.

  4. Your NSE then returns the content of the notification it actually received.

Any app can use the flow above; however, if your app has the filtering entitlement, then step #4 is optional, so you could just discard all of the "push" notifications and instead route everything through your local notification logic.

More to the point, there's a good argument that doing so is actually a BETTER architectural choice than going to the trouble of doing that "extra" push at the end. The issue here is that under real-world conditions, the range of possibilities quickly becomes fairly unwieldy and fairly weird.

For example, it's entirely possible that latency issues mean that the message data (embedded in the push) you're currently looking at is actually OLDER than the message data you end up getting from the server. You could "mask" that by shuffling the notifications you push such that the push you received is actually posted as a local notification and the most recent is sent as the NSE content; however:

  • That's a weird thing to try and logically think through in code.

  • It's entirely possible that you’ll shortly receive a new push for that most recent push, at which point you'll need to either discard THAT push or "fiddle" with notification content so you can repost the same notification again.

While it's certainly possible to make all of that work, it's actually somewhat easier to just filter "all" notifications and then have a "notification engine" which takes whatever data your NSE has/downloads and then posts everything it needs to through the local notification system. That way, there's only a single code path for every notification, and you don't have to worry about issues like the timing difference between your NSE returning data and local notification reporting.

In any case, it’s entirely possible that they’re doing what I’ve described above, which might explain the difference in behavior.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Hi Kevin,

Thanks for the detailed reply — really helpful framing on the local-notification path.

  1. Bug number

I’ll file a Feedback Assistant report referencing this thread and post the FB number here as soon as the form completes.

  1. System versions

Tested on the following device pairs:

  • iPhone 16 Pro on iOS 26.4.2 + Apple Watch Series 10 on watchOS 26.4
  • iPhone 16 Pro on iOS 26.5 + Apple Watch Series 10 on watchOS 26.5

The drift reproduces with the same test matrix on both 26.4 and 26.5, so the issue is still present on the current release version.

  1. Filtering entitlement

The “post local notifications from the NSE and discard the push” pattern you described is very relevant to our architecture. To confirm my understanding: in order to actually drop the original push and avoid duplicate notifications, the NSE would need the Notification Filtering entitlement, correct? My NSE currently doesn’t have it.

I have already submitted a request for the Notification Filtering entitlement, but it is still in the “Submitted” state. If this is indeed the right entitlement for the architecture you described, any guidance on the correct technical framing or whether there is a better path to follow would be very helpful.

  1. Diagnostic step before any refactor

Before going through a larger refactor, I plan to ship a small diagnostic build where the NSE additionally posts a local notification in parallel to returning the modified content via contentHandler.

The goal is to observe whether that local notification exhibits the same Watch Notification Center drift after the screen turns off and is reopened.

If the locally posted notification renders the correct timestamp while the contentHandler-returned notification drifts, that would help isolate the issue to the path that mirrors NSE-returned push content to Apple Watch, and would justify pursuing the entitlement + notification-engine refactor.

I’ll report back here with the FB number and the diagnostic build outcome. I’m happy to share an anonymized minimal reproducible Xcode project, NSE logs, screenshots, or the entitlement request details privately if useful.

Thanks again,

Stefano Bigioggero
Taski

Quick update with results from a diagnostic build I ran on iOS/watchOS 26.5.

To validate the distinction you described between the NSE-returned content path and the locally posted notification path, I shipped an internal TestFlight build where the NSE additionally posts a local UNNotificationRequest via UNUserNotificationCenter.add() in parallel with the standard contentHandler() call.

The local notification uses the same title and body as the NSE-returned notification, except for a [L] prefix in the body and a local-z- identifier prefix. The same [L] marker is visible on iPhone and Apple Watch, so the local-route notification can be reliably matched across both devices.

Test protocol:

  • Send a message with a known timestamp in the body, for example Timestamp 00:19
  • Wait for delivery on Apple Watch
  • Turn the Watch screen off
  • Wait approximately 2–3 minutes
  • Wake the Watch and open Notification Center

Result on Apple Watch, using iOS 26.5 + watchOS 26.5:

  • Local-route notification, [L] Timestamp 00:19: correct relative timestamp, for example “2 minutes ago” when viewed approximately 3 minutes after delivery.
  • NSE-route notification, Timestamp 00:19: incorrect relative timestamp, shown as “Now” despite the notification being several minutes old.

A second build with the local probe disabled, leaving only the original NSE-route path matching production, confirmed that the NSE-route notification continues to drift on its own. In that build, I observed its relative timestamp in Apple Watch Notification Center jump from “1 minute ago” to “14 minutes ago” within a few real minutes, i.e. non-monotonic timestamp updates unrelated to the actual delivery time.

On iPhone Notification Center, both paths render the correct timestamp consistently. The issue appears to be Watch-only.

One interesting note on the symptom in 26.5: on earlier versions, the drift typically appeared as “X minutes/hours ago” on a fresh notification. On 26.5, I’m now also seeing the inverse pattern — “Now” on a notification that is several minutes old — as if the relative timestamp is being re-anchored to a Watch wake or Notification Center refresh event rather than to the original delivery time. This appears consistent with the same underlying issue, just surfaced in a different way.

These results seem to support the theory you outlined: the issue appears specific to the path that mirrors NSE-returned push content to Apple Watch Notification Center, while routing the final notification through UNUserNotificationCenter.add() from the NSE preserves the correct timestamp on the Watch.

Given this, I would like to move forward with the architectural refactor you described: having the NSE post the final local notifications and dropping the original push via the Notification Filtering entitlement.

The entitlement request I submitted earlier is still in the “Submitted” state. If this is the right entitlement for that architecture, any guidance on the recommended follow-up channel, contact, or technical framing would be very welcome.

I’ll attach screenshots from the diagnostic build to the Feedback Assistant report when I file it, and I’ll post the FB number here as soon as it’s available.

Thanks again,

Stefano Bigioggero

The “post local notifications from the NSE and discard the push” pattern you described is very relevant to our architecture. To confirm my understanding: in order to actually drop the original push and avoid duplicate notifications, the NSE would need the Notification Filtering entitlement, correct? My NSE currently doesn’t have it.

Yes, that's what I was referring to. If you're implementing an end to end encrypted (E2EE) voip app, then you need that entitlement so that you route calls from your NSE over to PushKit. This is the flow described in "Sending End-to-End Encrypted VoIP Calls".

I have already submitted a request for the Notification Filtering entitlement, but it is still in the “Submitted” state. If this is indeed the right entitlement for the architecture you described, any guidance on the correct technical framing or whether there is a better path to follow would be very helpful.

Unfortunately, there is a significant backlog in request processing and I don't know how long it will take for your request to be approved.

Having said that, the NSE entitlement is NOT necessary to implement the kind of flow I've described, it simply a detail that explains how/why an app would be posting "all" of it's notifications as local notifications.

Before going through a larger refactor, I plan to ship a small diagnostic build where the NSE additionally posts a local notification in parallel to returning the modified content via contentHandler.

Sure. My suggestion would be to reroute your dynamic content to local push and then post a fixed message through your NSE. You might also consider printing your own time stamp in the message content so that any major divergence will be obvious.

If the locally posted notification renders the correct timestamp while the contentHandler-returned notification drifts, that would help isolate the issue to the path that mirrors NSE-returned push content to Apple Watch, and would justify pursuing the entitlement + notification-engine refactor.

So, let me be very clear here. The ONLY reason an app needs the NSE filtering entitlement is because it's an E2EE app that needs to route incoming voip call over to it's app. If you're not implementing a voip application, then you don't need the NSE filtering entitlement and you won't be granted it.

Note that I don't use the word "need" casually. The reality is that the NSE Filtering entitlement was created at a VERY specific point in time where the system effectively reported nearly every incoming alert push in a very "visible" way. In one of life great ironies, the system release (iOS 15) where we introduced the NSE Filtering entitlement was EXACTLY the same release when the notification system actively started to change that behavior. At this point, the notification system provides multiple mechanisms for deprioritizing notifications, such that if one of your notification isn't relevant to the user, then you can mark it as such and the system will do a good job of making sure the notification doesn't disrupt the user.

Indeed, the ONLY reason voip apps need the NSE filtering entitlement is that "reportNewIncomingVoIPPushPayload(_:completion:)" requires it[1]. Outside of that limitation, I think it would be entirely possible to make an E2EE encrypted voip app without any special entitlement.

[1] I don't think this restriction is really necessary and have a bug filed about removing it (r.102434375). If you'd also like this restriction removed, please file you own bug asking for it to be eliminated and post the bug number back here.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks Kevin, that clarification helps a lot.

A clarification on Taski’s side: today our audio/video calls already use E2EE for the media stream, but the incoming-call VoIP push payload itself is not yet E2EE. The APNs payload currently includes call-related fields such as caller ID, caller name, and call type in cleartext.

The signaling side is exactly the gap your “Sending End-to-End Encrypted VoIP Calls” guidance is designed to close, and it is where we would like to evolve next: the backend would send a mutable-content push with an encrypted call payload, our NSE would decrypt it, and then promote it to PushKit via reportNewIncomingVoIPPushPayload(_:completion:).

That would let us make Taski’s incoming-call architecture fully E2EE, including the call-related payload fields currently visible in the VoIP push.

That is the framing in which the Notification Filtering entitlement is actually needed for us. If the recommended process is to withdraw the existing request and resubmit it with this VoIP framing, I’m happy to do that. Otherwise, I can add a comment/update to the existing request. Any preference?

For the watchOS Notification Center timestamp issue, I’ll proceed with the local-route refactor you described in parallel, without waiting on the entitlement. I’ll keep tracking that separately via FB22781345.

I’ll also file the duplicate of r.102434375 asking for the reportNewIncomingVoIPPushPayload(_:completion:) entitlement restriction to be removed, and post the bug number here.

Thanks again,

Stefano

Apple Watch Notification Center timestamp drift on notifications processed by a Notification Service Extension
 
 
Q