Every notification tap crashes on iOS 26.6

If your app crashes the moment someone taps one of its notifications, with an uncaught NSInternalInconsistencyException that says “Call must be made on main thread”, look at how you implemented UNUserNotificationCenterDelegate. If you used the Swift async versions of didReceive and willPresent, that is the cause. Switch to the completion-handler versions and call the handler on the main thread. The crash goes away.

Why the async version crashes

UNUserNotificationCenterDelegate is an Objective-C protocol. Its methods take a completion block: userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: and userNotificationCenter:willPresentNotification:withCompletionHandler:. Swift also lets you write them as async methods, and Apple’s reference lists the async form as an alternative. What it doesn’t say is which thread the completion runs on.

When an async method stands in for one of these Objective-C methods, the compiler bridges it. It starts a Task that awaits your method and then calls UIKit’s completion block from whatever thread that Task resumes on. A delegate that isn’t isolated to the main actor runs on the cooperative thread pool, so the completion block is called off the main thread.

On iOS 26.6, UIKit’s didReceive completion runs the app’s snapshot and state-restoration update (-[UIApplication _updateSnapshotAndStateRestorationWithAction:windowScene:]). That method asserts it is on the main thread. The result is an uncaught exception on every tap.

I’m not the first to see this. A 2023 thread on Apple’s developer forums is titled “Crash: Call must be on main thread”. An Apple DTS engineer explains there that the callback-based methods aren’t declared @MainActor, but they are called on the main thread. The async versions run on the global concurrent executor, so they hop off it. Keeping the callback-based API is the first fix suggested in that answer.

Before

This is roughly what I had, from the Catalyst iPhone app. A tap parks a route in the app’s router, and the chat opens once the UI is ready:

final class PushNotificationDelegate: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable {
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else { return }
        guard let route = JobNotificationPayload.route(
            from: response.notification.request.content.userInfo) else { return }
        await MainActor.run {
            AppRouter.shared.enqueue(route)
        }
    }
}

The await MainActor.run doesn’t help, and that’s the trap. My own work does happen on the main actor. But the method then returns on the cooperative pool, and the bridge’s completion call runs there.

After

The completion-handler methods give the completion block back to you, so you choose the thread it runs on. If UIKit called you on the main thread, which is what happens in practice, do the work and answer at once. If not, hop to the main queue first:

final class PushNotificationDelegate: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable {
    static let shared = PushNotificationDelegate()

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        let sessionId = JobNotificationPayload.sessionId(from: notification.request.content.userInfo)
        nonisolated(unsafe) let finish = completionHandler
        Self.onMain {
            finish(JobNotificationCoordinator.shared.presentationOptions(forSessionId: sessionId))
        }
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        // Only a tap on the notification navigates; a dismiss still has to complete.
        let route: AppRoute? = response.actionIdentifier == UNNotificationDefaultActionIdentifier
            ? JobNotificationPayload.route(from: response.notification.request.content.userInfo)
            : nil
        nonisolated(unsafe) let finish = completionHandler
        Self.onMain {
            if let route { AppRouter.shared.enqueue(route) }
            finish()
        }
    }

    /// Synchronously when already on main, otherwise on the next main-queue turn.
    /// Never on the calling thread when that thread is not main.
    private static func onMain(_ body: @escaping @MainActor @Sendable () -> Void) {
        if Thread.isMainThread {
            MainActor.assumeIsolated(body)
        } else {
            DispatchQueue.main.async { body() }
        }
    }
}

A few details matter:

  • Call the handler exactly once, on every path. A dismiss or an action you don’t handle still calls finish(). UIKit waits for it whatever the action was.
  • Park the route before you call the handler. On the main thread the whole closure runs synchronously, so the router already has the route when UIKit gets its answer.
  • nonisolated(unsafe) let finish is there for Swift 6 strict concurrency. The project builds in the Swift 6 language mode with complete checking, and this line lets the completion closure, declared without @Sendable, be captured by the main-actor closure. It’s called once, on main, so that’s safe.
  • Set the delegate early. Apple’s header says the delegate must be set before application(_:didFinishLaunchingWithOptions:) returns. Otherwise a cold launch from a tap can lose that tap. I set it there in a UIApplicationDelegate adaptor, even though the app is SwiftUI.

Verify it

Three checks.

  1. A unit test that calls the handler from a background queue. In the real class the two bodies are static functions, presentationOptions(userInfo:completion:) and handleResponse(actionIdentifier:userInfo:completion:), so a test can call them directly. The test calls handleResponse from DispatchQueue.global() and asserts that the completion ran on the main thread, ran exactly once, and that the route was parked. A second test calls it on main and checks that the completion ran synchronously, after the route was parked. A third sends UNNotificationDismissActionIdentifier and checks that nothing navigates but the completion still runs.
  2. A tap on a device. Send a notification to a real phone, background the app, and tap it, then do it again with the app in the foreground. The simulator delivers local notifications too, but I found this crash on the phone.
  3. The phone’s own crash report, if it still crashes. When the backtrace Xcode shows is cut short, pull the device’s report over USB instead of guessing:
uvx pymobiledevice3 crash pull ./crashes

That needs no root and no tunnel. Open the app’s .ips file and read lastExceptionBacktrace. The app’s frames are already symbolicated.

How I ran into it

On September 8, 2026 I added a “notify me when a reply is ready” feature to the Catalyst iPhone app. A long AI reply can take a while, people leave the app, and a notification brings them back to the chat when the reply lands. That morning’s version used the async delegate methods.

On my iPhone, on iOS 26.6, every tap on the notification crashed the app. My first guess was wrong. The same feature holds a background task while a reply streams, and I blamed its expiry handler. I gave that handler a synchronous hop to the main thread, which stayed as a belt-and-braces check, but it wasn’t the crash. The phone’s crash reports settled it. The crashing frame was the compiler’s @objc closure #1 in userNotificationCenter(_:didReceive:): the bridge itself, calling UIKit’s completion off the main thread.

Moving to the completion-handler methods, with the tests above, fixed it within the morning. The async version never shipped. It’s now my rule for every app with notifications: completion-handler delegate methods, handler called on the main thread, never the async variants.

By: Gavi Narra on: