How do Notification Service and Notification Content extensions work: mutable content, the deadline, attachments, decryption, and what goes wrong?

19.2.1 · L5 · Interview

Predict first

A service extension takes a mutable copy of the content and edits its title. What do the copy, the original and the user info show?

import UserNotifications

let c = UNMutableNotificationContent()
c.title = "enc:abc"
c.body = "enc:xyz"
c.userInfo = ["id": 7]
let m = c.mutableCopy() as! UNMutableNotificationContent
m.title = String(m.title.dropFirst(4))
print(m.title, c.title, m.userInfo["id"] as? Int ?? 0)
  • abc enc:abc 7
  • abc abc 7
  • enc:abc abc 0

Mental model. A service extension is a mail-room clerk who opens each letter marked “process me”, adds a photo or translates it, and must put it in the outgoing tray before the bell rings in half a minute. If the bell rings first, the original letter is delivered as it was.

A Notification Service Extension (UNNotificationServiceExtension, the com.apple.usernotifications.service point) lets your app modify a remote notification before it is shown: decrypt the text of an end-to-end encrypted message, download and attach an image or video, localise the body, add a thread identifier, or look up a name. It is triggered only when the push payload says "mutable-content": 1 in aps and the notification is a visible alert (a silent background push does not run it). The system instantiates your principal class and calls didReceive(_ request:, withContentHandler:) with the incoming request; you take a mutable copy (request.content.mutableCopy() as? UNMutableNotificationContent, because the original is immutable), change its title, body, userInfo, sound or attachments, and call contentHandler(bestAttempt). The copy is independent: after changing the copy’s title from enc:abc to abc, the original request still says enc:abc. You have a limited time (about 30 seconds) and low memory; just before the time runs out the system calls serviceExtensionTimeWillExpire(), where you must immediately call the content handler with the best content you have, otherwise the system delivers the original, unmodified notification. The rule: always call the handler exactly once, on every path, including errors and timeouts, with a graceful fallback (a generic body such as “New message” rather than ciphertext).

Design notes. Attachments (UNNotificationAttachment(identifier:url:options:)) need a local file URL: download the media with a URLSession data or download task into a temporary or shared-container file with the right extension, since the system infers the type from it, and keep the file small (large downloads exceed the time and memory budget). Decryption: keys live in the shared Keychain access group, never in the payload; the extension reads them, decrypts, and must stay within memory. Do not rely on it for correctness: it can be skipped (the user disabled notifications, low power, a killed process), the push can be delivered as is, and some paths (a notification arriving while the device is in certain states) may not run it, so the unmodified payload must still be meaningful and must not contain secrets in clear text. No UI, no app launch: you cannot present anything or run long work; if you need to update app state, write to the shared container and let the app pick it up. It does not run for local notifications or silent pushes. A Notification Content Extension (UNNotificationContentExtension, a UIViewController or SwiftUI view) provides a custom expanded UI when the user long-presses or expands the notification: it is chosen by the category identifier listed in the extension’s UNNotificationExtensionCategory and can show an image gallery, a map, a live score, and handle the category’s actions (and optionally accept user input). It has the same small budget, runs only while the notification is expanded, and its content comes from the notification’s userInfo and attachments, so put what it needs there. Test with real pushes (the simulator can deliver them with .apns files through simctl push), use Console logging with the extension’s subsystem, and watch memory.

The edited copy has the decrypted title, the original is unchanged, and the user info was copied over.

Common traps

  • Not calling the content handler on an error path or after the deadline. The system delivers the original notification; always call it once with a fallback.
  • Forgetting mutable-content: 1 in the payload. The extension never runs, which looks like “my extension is broken”.

They might follow up with

  • Why take a mutable copy? The incoming content is immutable, and the copy’s changes do not alter the original request.
  • What does serviceExtensionTimeWillExpire require? Calling the handler immediately with the best content so far.

Related questions

  • 19.1.1 What is an app extension, how is it launched, and what limits do extensions run under?
  • 19.1.2 How do app groups, shared containers and keychain access groups let an app and its extensions share data, and…
  • 17.1.2 How does the Apple Push Notification service deliver a push: tokens, priority, collapse, expiry, and what can…