Skip to main content
All articles
SwiftUIFeatured

Where Handoff Actually Arrives on iOS and macOS

Ben Van AkenCo-Founder & CTO10 min read

Part 2 built an NSUserActivity, marked it eligible for Handoff and made it current. This part is about the other end of the transfer: the callback that receives it. It is the one place in Continuity where the platform difference is not cosmetic. The iOS seam moved to a different object in iOS 13, the macOS seam never moved, and a single cross-platform "receive seam" abstraction that pretends otherwise is wrong in a way that produces silence rather than an error.

The callback that never fires

Since iOS 13, once an app declares UIApplicationSceneManifest in its Info.plist, UIKit stops calling the app-delegate continuation methods and delivers the activity to UISceneDelegate.scene(_:continue:) instead. This is long-standing routing, not a new deprecation: it has been the behaviour since iOS 13.

A SwiftUI app that declares a scene manifest is therefore in this position by default, and that is the normal shape for anything supporting multiple windows. In a production SwiftUI app we build, deliberately not named, the iOS client declares the manifest with multiple scenes enabled and attaches its app delegate through @UIApplicationDelegateAdaptor. An internal design there had chosen the app-delegate seam over the SwiftUI modifier partly to avoid an untrodden scene-manifest edit. That has it backwards: the scene-manifest edit is not the thing being avoided, it is the thing delivery requires.

The failure mode is what makes this expensive. Nothing is logged. No assertion fires, no crash, no warning in the console. Your app-delegate method is simply not the method UIKit calls, so there is nothing to grep for and no signal to distinguish "my activity never arrived over the air" from "my activity arrived and went to an object I never wrote". Teams debug the transport for days because the transport is the only part that produces observable behaviour.

There is a second, independent reason the same method can stay silent, and it is documented: Apple notes that application(_:continue:restorationHandler:) is not called at all if application(_:didFinishLaunchingWithOptions:) returned false. Two different causes, one symptom.

The scene-delegate receive

The seam itself is three methods on UISceneDelegate, mirroring the app-delegate set they replaced:

Swift
import UIKit

final class AppSceneDelegate: NSObject { }

extension AppSceneDelegate: UIWindowSceneDelegate {

  // This is the method UIKit calls once the
  // app declares a scene manifest.
  func scene(
    _ scene: UIScene,
    continue activity: NSUserActivity
  ) {
    guard activity.activityType == Handoff.type
    else { return }
    ContinuationRouter.shared.restore(activity)
  }

  func scene(
    _ scene: UIScene,
    willContinueUserActivityWithType type: String
  ) {
    ContinuationRouter.shared.willArrive(type)
  }

  func scene(
    _ scene: UIScene,
    didFailToContinueUserActivityWithType type: String,
    error: any Error
  ) {
    ContinuationRouter.shared.failed(type, error)
  }
}

A SwiftUI app with an @UIApplicationDelegateAdaptor still needs to hand UIKit that class, which happens in the scene-configuration callback on the app delegate:

Swift
extension AppDelegate {
  func application(
    _ app: UIApplication,
    configurationForConnecting session: UISceneSession,
    options: UIScene.ConnectionOptions
  ) -> UISceneConfiguration {
    let config = UISceneConfiguration(
      name: nil,
      sessionRole: session.role
    )
    config.delegateClass = AppSceneDelegate.self
    return config
  }
}

How we know: availability from the markdown twin

The figures below are not read off Apple's prose, and that matters, because the prose is wrong about them. Every documentation page on developer.apple.com has a markdown twin: append .md to the URL and you get the page as source, with an HTML-comment header at the top carrying machine-readable per-platform availability. That header is generated from the SDK, so it leads; the human-written page around it lags.

Bash
# Any documentation page, fetched as its
# markdown twin. The comment header at the
# top carries per-platform availability.
BASE=https://developer.apple.com/documentation
PAGE=uikit/uiapplicationdelegate

curl -sL "$BASE/$PAGE.md" | head -40

Fetched that way on 23 September 2026, the three UIKit app-delegate Handoff methods carry an upper bound and their replacements do not:

  • UIApplicationDelegate.application(_:continue:restorationHandler:). iOS 8.0–26.0, iPadOS 8.0–26.0, Mac Catalyst 13.1–26.0, tvOS 9.0–26.0, visionOS 1.0–26.0.
  • UIApplicationDelegate.application(_:willContinueUserActivityWithType:). The same upper-bounded shape.
  • UIApplicationDelegate.application(_:didFailToContinueUserActivityWithType:error:). The same upper-bounded shape.
  • UISceneDelegate.scene(_:continue:). iOS 13.0–, iPadOS 13.0–, Mac Catalyst 13.1–, tvOS 13.0–, visionOS 1.0–. Open-ended.
  • NSApplicationDelegate.application(_:continue:restorationHandler:). macOS 10.10–. Open-ended.

Apple's prose has not caught up. None of the three appear on UIApplicationDelegate's Deprecated symbols page, and the protocol page still lists them under a live "Continuing user activity" heading. Two independent fetches of that page in the same pass agreed. Treat the metadata as leading and the prose as lagging. Re-fetch before you act on any of these rows, because an SDK release changes them and this article will not.

TN3187, and the inference we are making

The mechanism behind the bound is Apple's scene-lifecycle mandate, stated in TN3187, Migrating to the UIKit scene-based life cycle (published 2025-05-05, updated 2025-06-23 and 2026-03-16). Two passages, verbatim:

In iOS 18.4, iPadOS 18.4, Mac Catalyst 18.4, tvOS 18.4, visionOS 2.4 and later, UIKit logs the following message for apps that haven't adopted the scene-based life-cycle: This process does not adopt UIScene lifecycle. This will become an assert in a future version.

Apple, TN3187: Migrating to the UIKit scene-based life cycle

In the next major release following iOS 26, UIScene lifecycle will be required when building with the latest SDK; otherwise, your app won't launch.

Apple, TN3187: Migrating to the UIKit scene-based life cycle

An Apple DTS engineer confirmed the consequence in developer forum thread 820807 in March 2026: failing to adopt the scene-based life cycle in the next major iOS release will prevent the app from launching.

Now the honest caveat, because this is the step where a confident-sounding article would overreach. TN3187's own migration table maps only the four lifecycle callbacks. It does not name the three Handoff methods anywhere. The link from the mandate to these symbols is an assembled inference. The availability metadata says 8.0–26.0, the technote says scenes become mandatory after 26, and the two fit. But it is not quoted from a single Apple sentence. It is well-supported. It is not a citation.

Two related questions were looked for and not found. Whether enforcement keys off the build SDK or the runtime OS is asserted by third-party blogs to be the build SDK; Apple DTS left the direct question unanswered. And whether SwiftUI-lifecycle apps are formally exempt from TN3187 is reasonably inferable (they get scenes by construction) but never stated.

macOS is a different contract

AppKit has no scene-adoption mandate, no UIScene, and no equivalent transition underway. The NSApplicationDelegate Handoff methods carry no upper bound. On macOS the app delegate is the correct, current, supported receive seam, and the design that is wrong on iOS is right here.

Swift
import AppKit

typealias RestoreHandler =
  ([any NSUserActivityRestoring]) -> Void

final class MacAppDelegate: NSObject { }

extension MacAppDelegate: NSApplicationDelegate {

  func application(
    _ app: NSApplication,
    willContinueUserActivityWithType type: String
  ) -> Bool {
    ContinuationRouter.shared.willArrive(type)
    return type == Handoff.type
  }

  func application(
    _ app: NSApplication,
    continue activity: NSUserActivity,
    restorationHandler: @escaping RestoreHandler
  ) -> Bool {
    guard activity.activityType == Handoff.type
    else { return false }
    ContinuationRouter.shared.restore(activity)
    return true
  }

  func application(
    _ app: NSApplication,
    didFailToContinueUserActivityWithType type: String,
    error: any Error
  ) {
    ContinuationRouter.shared.failed(type, error)
  }
}

One AppKit-specific behaviour worth knowing: returning false from the continue callback lets the system auto-restore document activities rather than dropping them. That is a deliberate escape hatch for document-based apps, not an error path.

So the shared abstraction you may be reaching for (one ContinuityReceiver protocol both platforms conform to) is fine as a place to put your restore logic, and wrong as a place to put the callback. The callbacks are per platform. Write both.

Three callbacks, not one

Handoff arrival is not a single event. The system tells you it is coming, then tells you it arrived or failed, and the gap between the first and the second is long enough to matter. Apple describes the continue callback as running "on your app's main thread only after it receives all of the data for an activity object". So on a slow link there is a real interval during which the user has tapped the Handoff affordance and your app has shown them nothing.

That is what willContinueUserActivityWithType is for: it is the loading-state hook. And on macOS, Apple documents an explicit guarantee about what follows it:

the app delegate is guaranteed to get exactly one invocation of application(_:continue:restorationHandler:) on success, or application(_:didFailToContinueUserActivityWithType:error:) if an error was encountered

Apple, NSApplicationDelegate.application(_:willContinueUserActivityWithType:)

Read that sentence for what it excludes. Silence is not a documented outcome. Exactly one of two things follows. Which means a UI that shows a spinner on willContinue and dismisses it only in the success path is not being pessimistic about a rare case. It is ignoring one of the two documented outcomes, and it will hang on the one that actually happens in a lift with no Wi-Fi.

Implement all three. The failure callback is where you tell the user the transfer did not complete, and it is the only place you will ever see the error the system produced.

The SwiftUI modifier, and the macOS reports

SwiftUI's own seam is .onContinueUserActivity(_:perform:), available since iOS 14 and macOS 11. The type argument does the filtering, so the closure only runs for activity types you asked for:

Swift
@main
struct DocumentApp: App {
  var body: some Scene {
    WindowGroup {
      RootView()
        .onContinueUserActivity(
          Handoff.type
        ) { activity in
          ContinuationRouter.shared
            .restore(activity)
        }
    }
  }
}

Two things to know before you rely on it. First, it is not the universal-link seam: Apple's guidance is to use .onOpenURL for NSUserActivityTypeBrowsingWeb, not this modifier. Second, the companion .userActivity(_:element:_:) modifier is scoped: "the scope of the activity applies only to the scene or window the view is in". That is the subject of part 5, and it is easy to misread as app-wide.

And then there are the reports. Six developer forum threads between 2020 and 2025 (760522, 669301, 658827, 762526, 667004 and 691275) report the same thing: on a SwiftUI macOS app, the .onContinueUserActivity closure never runs. No Apple engineer replied in any of them. No root cause was established and no workaround was posted; one thread offers an unrelated .onOpenURL suggestion.

One plausible contributing cause has been published, by The SwiftUI Lab in September 2020: the NSUserActivity-driven SwiftUI modifiers silently no-op when a UIKit scene delegate is mixed into a SwiftUI-lifecycle app. That is directly relevant to an app that mixes one in for CarPlay, a widget or a quick-create scene. That is a very ordinary thing to have done.

The practical consequence is small and cheap: do not build your macOS restore path on the SwiftUI modifier alone without measuring that it fires in your app, on your macOS version, with your scene configuration. Keep the NSApplicationDelegate implementation as the path you trust, and treat the modifier as a convenience you have verified rather than one you have assumed.

Prove which seam fires before you build on it

All of the above collapses into one question you can answer in ten minutes: in this app, on this OS, which callback actually runs? Implement every candidate seam at once, have each one announce itself, and let the system tell you.

Swift
enum SeamProbe {
  /// Announces which receive seam the system
  /// actually called. Keys only. Never log
  /// a payload value (see part 6).
  static func fired(
    _ seam: String,
    _ activity: NSUserActivity?
  ) {
    #if DEBUG
    let type = activity?.activityType ?? "nil"
    let keys = activity?.userInfo?.keys
      .map { "\($0)" }
      .sorted() ?? []
    print("[handoff] \(seam) t=\(type) k=\(keys)")
    #endif
  }
}

Then call it from the first line of each candidate, and run a real Handoff:

Swift
// UIApplicationDelegate.application(_:continue:…)
SeamProbe.fired("app-delegate", activity)

// UISceneDelegate.scene(_:continue:)
SeamProbe.fired("scene-delegate", activity)

// SwiftUI .onContinueUserActivity
SeamProbe.fired("swiftui-modifier", activity)

// NSApplicationDelegate.application(_:continue:…)
SeamProbe.fired("appkit-delegate", activity)

Exactly one line printing tells you where to put your restore logic on that platform. No line printing tells you the activity never arrived, which is a transport problem and a different investigation. Part 9 covers the six conditions that account for most of those. Two lines printing would be the most interesting result of all, for the reason in the next section.

Keep the probe. It costs nothing in a release build, it is the only artefact in the whole feature that distinguishes a routing failure from a transport failure, and the routing can change under you when you adopt a new SDK.

What Apple does not document

The most load-bearing sentence in this article is one Apple has not written. Across all six delivery-path pages checked in this pass, Apple never documents a precedence rule for what happens when both an app-delegate method and a scene-delegate method are implemented. "Scenes win" is community-sourced and long-standing practice. It is consistent with the availability metadata, it matches what developers report, and we have no Apple sentence that states it.

So the honest position is: implement the scene seam because that is the one with an open-ended availability window and the one the reports describe being called; do not implement both and rely on an ordering; and if you have both today, use the probe above rather than reasoning about which should win.

Three further gaps sit around this part, and they are findings rather than omissions. Each was looked for and not found:

  • No Apple statement of app-delegate versus scene-delegate precedence, on any delivery-path page.
  • No confirmation of whether the scene mandate is enforced against the build SDK or the runtime OS. Third-party blogs say build SDK; Apple DTS did not answer the direct question.
  • No statement that SwiftUI-lifecycle apps are formally exempt from TN3187, although they satisfy it by construction.

Part 4 turns to what you are allowed to put in the activity you are now, finally, receiving. That includes the one payload figure Apple published, on a page it archived in 2016 and never restated.

Previously (Part 2): Your First NSUserActivity: Handoff in Swift

Next up (Part 4): What Fits in a Handoff Payloadlands in 3 days

Comments

Loading comments…

Shipping Handoff across iPhone, iPad and Mac?

We build native SwiftUI and AppKit apps where continuity works on every device the customer owns. Book a call and let's look at your receive seams.

Keep reading

SwiftUI

Your First NSUserActivity: Handoff in Swift

Part 2 of our Apple Continuity series: the smallest Handoff that works end to end. Build the activity, declare NSUserActivityTypes in every receiving target, advertise and receive it in SwiftUI. The same Team ID plus a declared type is the entire gate.

Ben Van Aken8 min read
SwiftUIlands in 3 days

What Fits in a Handoff Payload

Part 4 of our Apple Continuity series. Apple does publish a Handoff payload figure of 3 KB, but only as a recommendation, and only on one archived 2016 page. What that licenses, what nobody has measured, how setTypedPayload changes the ergonomics without changing the budget, and why a payload builder should refuse rather than truncate.

Ben Van Aken11 min read
SwiftUIlands in 5 days

Sending Handoff to the Right Window

Part 5 of our Apple Continuity series. Arriving in the app is not the same as arriving in the right window. SwiftUI picks the scene for a continuation in four documented steps, and it matches on a property most Handoff tutorials never set: targetContentIdentifier.

Ben Van Aken9 min read