Notification Service Extension is killed during startup

We are observing an issue where the iOS Notification Service Extension (NSE) is terminated by the system during startup, before either didReceive(_:withContentHandler:) or serviceExtensionTimeWillExpire(_:) is invoked. When this occurs, the notification is delivered without modification (for example, an encrypted payload is shown to the user). System logs frequently contain the message “Extension will be killed because it used its runtime in starting up”.

During testing, we observed that CPU-intensive operations or heavy initialization performed early in the extension lifecycle — especially inside init() or directly on the main thread in didReceive often cause the system to kill the NSE almost immediately. These terminations happen significantly earlier than the commonly observed ~30-second execution window where the OS normally invokes serviceExtensionTimeWillExpire(_:) before ending the extension. When these early terminations occur, there is no call to the expiry handler, and the process appears to be forcefully shut down.

Moving the same operations to a background thread changes the behavior: the extension eventually expires around the usual 30-second window, after which the OS calls serviceExtensionTimeWillExpire(_:).

We also observed that memory usage plays a role in early termination. During tests involving large memory allocations, the system consistently killed the extension once memory consumption exceeded a certain threshold (in our measurements, this occurred around 150–180 MB). Again, unlike normal time-based expiration, the system did not call the expiry handler and no crash report was produced.

Since Apple’s documentation does not specify concrete CPU, memory, or startup-cost constraints for Notification Service Extensions or any other extensions beyond the general execution limit, we are seeking clarification and best-practice guidance on expected behaviors, particularly around initialization cost and the differences between startup termination.

NSE Setup:

class NotificationService: UNNotificationServiceExtension {

    static var notificationContentHandler: ((UNNotificationContent) -> Void)?
    static var notificationContent: UNMutableNotificationContent?
    static var shoudLoop = true

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {

        NotificationService.notificationContentHandler = contentHandler
        NotificationService.notificationContent =
            request.content.mutableCopy() as? UNMutableNotificationContent

        NotificationService.notificationContent!.title = "Weekly meeting"
        NotificationService.notificationContent!.body = "Updated inside didReceive"
        
        // Failing scenarios
    }

    override func serviceExtensionTimeWillExpire() {
        NotificationService.shoudLoop = false

        guard let handler = NotificationService.notificationContentHandler,
              let content = NotificationService.notificationContent else { return }

        content.body = "Updated inside serviceExtensionTimeWillExpire()"
        handler(content)
    }
}

The Notification Service Extension is limited to 24 MB. Once you past that threshold, the system will mark the extension process for termination, and it will be killed at an appropriate time.

The larger amounts you might be observing could be your memory footprint growing past 24 MB until the system terminates the process.

The 24 MB is the total memory allowed, which includes your code, any linked libraries/frameworks, and in-process allocation.

This is a jetsam event, so it will show in the jetsam logs, and not as a crash log.


Argun Tekant /  WWDR Engineering / Core Technologies

Thank you for the clarification about the 24 MB memory limit and jetsam-based termination for Notification Service Extensions. I wanted to share two specific behaviors I'm observing in our extension related to heavy CPU work, because both scenarios produce different outcomes.

Scenario 1 — Heavy CPU loop on a background thread

DispatchQueue.global(qos: .userInitiated).async {
    var sum: Int = 0
    for i in 0..<5_000_000_000 {
        sum += i
        if i % 100_000_000 == 0 {
            Log(String(format: "Scenario: Progress i=%d", i))
        }
        if !NotificationService.shoudLoop { break }
    }
    NotificationService.notificationContent!.title = "[Scenario Finished]"
    NotificationService.notificationContent!.body = "CPU-intensive work done"
    NotificationService.notificationContentHandler!(NotificationService.notificationContent!)
}

Observation: In this case, the Notification Service Extension stays alive long enough for didReceive(_:withContentHandler:) to complete. The notification text is successfully decrypted and displayed. As for the logs, of the Progress of the loop on the background thread, it gets executed till around i=100_000_000 and after that no logs are printed for the loop, as process gets terminated.

Scenario 2 — The same CPU loop but on the main thread

Log("Scenario 2A: Blocking main thread in didReceive")
var sum = 0
for i in 0..<5_000_000_000 {
    sum += i
    if i % 100_000_000 == 0 {
        Log(String(format: "Scenario 2A: Progress i=%d", i))
    }
    if !NotificationService.shoudLoop { break }
}

NotificationService.notificationContent!.title = "[Scenario 2 on main thread Finished]"
NotificationService.notificationContent!.body = "CPU-intensive work done"
NotificationService.notificationContentHandler!(NotificationService.notificationContent!)

Observation:

When this same long-running loop runs directly on the main thread, the extension becomes unresponsive. The system logs show messages such as:

tearing down context in extension due to invalidation [xpcservice...]

[xpcservice<world.tally.IOSPushNotifications.NotificationServiceExtension([osservice<com.apple.SpringBoard>:34])>{vt hash: 0}:1962] Set jetsam priority to 0 [0] flag[1]

The extension is terminated immediately, and the notification appears with the original encrypted payload — meaning didReceive never got a chance to complete. The expiry callback (serviceExtensionTimeWillExpire()) is also never invoked.

Could you help explain the reason behind the different behaviours when the work runs on the main thread versus a background thread? Are there any recommended best practices for what type of work should (or should not) run on the main thread inside a Notification Service Extension?

Also, if an extension needs to perform some initialisation work, are there guidelines on where that work should be placed to avoid premature termination?

Notification Service Extension is killed during startup
 
 
Q