iOS Universal Links Break on Cold Start After the UIScene Migration
If you upgraded to Flutter 3.38 or later, migrated to the UIScene lifecycle, and your Universal Links stopped opening the app when it was not already running, you have not misconfigured anything. This is a known open bug. If you have not completed baseline Universal Links setup yet, start with the iOS Universal Links setup guide first so you can rule out AASA and entitlement issues before blaming the migration.
What Apple changed
Apple introduced the UIScene lifecycle in iOS 13 and spent years suggesting apps adopt it. That suggestion is now a requirement. Apple's position, quoted in Flutter's own breaking change notes, is that in the release following iOS 26 any UIKit app built with the latest SDK must use the UIScene lifecycle or it will not launch.
The change is narrower than it first sounds. UIApplicationDelegate is not going away. It keeps process level events like launch and termination. What moves to the scene delegate is everything about the user interface: window ownership, foreground and background transitions, state restoration, and URL handling.
That last item is the one that matters here.
Flutter added the APIs for this migration in 3.38, and there is an experimental automatic migration for projects whose AppDelegate has not been customised:
flutter config --enable-uiscene-migration
flutter run
If your AppDelegate has custom native code, the automatic path tends not to work and you migrate by hand.
What breaks
Under the old model, a Universal Link arriving while the app was not running was delivered to:
application(_:continue:restorationHandler:)
Under the scene model, the equivalent is:
scene(_:continue:)
The bug, tracked as flutter/flutter issue 179452, is that after migrating, scene(_:continue:) is invoked only when the app is already running. On a cold start it is not called at all. Worse, plugins that have not yet adopted the scene lifecycle do not receive the AppDelegate fallback either, so there is no path for the link to arrive.
The issue was opened in December 2025, is labelled reproducible, and is confirmed present in both 3.38 and 3.39. As of August 2026 it remains open and unassigned.
A separate report, issue 183586, describes a native level crash on deep link handling after migrating on 3.38.10, with no Flutter level logs.
Why this is easy to misdiagnose
The failure has three properties that send people in the wrong direction.
It is silent. No exception, no console warning, no error surfaced to Dart. The app opens and sits on its default screen.
It is state dependent. Tapping a link with the app already backgrounded works fine. The same link with the app killed does nothing. Most people test the first case, because that is the convenient one during development.
It looks like an AASA problem. Universal Links failing to open the app is normally an associated domains or apple-app-site-association issue, so that is where everyone looks first. If your AASA file was working before the upgrade, it is almost certainly still working. Work through Why your AASA verification keeps failing to confirm delivery path and file validity before you touch the configuration.
How to confirm it is this and not something else
Put a breakpoint or a log line in both handlers.
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
print("scene continue: \(userActivity.webpageURL?.absoluteString ?? "none")")
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
print("willConnectTo activities: \(connectionOptions.userActivities.count)")
print("willConnectTo urls: \(connectionOptions.urlContexts.count)")
}
Then test in this order, on a real device, not the simulator:
- App in foreground, tap link
- App backgrounded, tap link
- App fully killed via the app switcher, tap link
If cases 1 and 2 log and case 3 does not, you are looking at this bug rather than a configuration problem.
That third case is worth understanding on its own terms. On a cold launch, iOS delivers the launch context through scene(_:willConnectTo:options:) rather than through the continue or openURLContexts callbacks, so an implementation that only handles the latter has nothing to read on a fresh start.
What still works
Deferred deep linking is not affected, and the reason is worth being precise about.
Deferred deep linking answers a different question. It asks whether an install came from a link, and that answer is retrieved over HTTP after the app starts rather than delivered by iOS through a lifecycle callback. Nothing in that path depends on application(_:continue:) or scene(_:continue:). Read What is deferred deep linking? for the full match flow, and see Flinku's deferred deep linking docs for SDK integration.
The Flinku SDK works this way. It does not register for Universal Links at all. The Flutter plugin registers a single method channel for the Android install referrer, and the iOS SDK exposes configure() and match(). When your app starts, match() makes an HTTP request and Flinku answers whether this install came from a link.
That is not a design choice made in anticipation of this bug. It is simply what deferred deep linking requires, and the side effect is that the UIScene migration does not touch it.
The practical split:
- Deferred deep linking, meaning a user who did not have your app, installed it from a link, and needs to land on the right screen: unaffected.
- Universal Link routing for an already installed app, meaning a user taps a link and your app opens to a specific screen: this is handled by your own URL routing, commonly
app_linksoruni_linksplus your delegate wiring, and this is what the bug affects.
If your app uses Flinku for install attribution and a separate package for URL routing, the first half keeps working through the migration and the second half is what needs attention.
What to do
There is no clean fix while the issue is open, but there are workable positions.
Handle the cold start case explicitly. Read connectionOptions.userActivities inside scene(_:willConnectTo:options:) and forward the URL yourself rather than relying on scene(_:continue:) being called. This is the most direct workaround and it is within your own code. The iOS Universal Links setup guide covers entitlements and AASA; pair that baseline with explicit cold start handling in your scene delegate.
Check whether your deep link package has adopted the scene lifecycle. Flutter's migration guidance for plugins is to conform to FlutterSceneLifeCycleDelegate while remaining registered on the application delegate, so apps that have not migrated keep working. A package that has done neither will not receive cold start links.
Test the killed app case in CI or on a checklist. This bug survives because the convenient test is the warm one. Make the cold start case something you actually run.
Do not delay the migration. It is not optional. Apps built with the latest SDK will stop launching. Migrating early with a known workaround is better than migrating under deadline pressure with the same bug and less time.
Frequently asked questions
Is this bug fixed yet?
As of August 2026, flutter/flutter issue 179452 is open, labelled P2, unassigned, and confirmed in both 3.38 and 3.39. Check the issue for current status before assuming a newer release resolves it.
Can I skip the UIScene migration?
No. Apple's stated position is that in the release following iOS 26, any UIKit app built with the latest SDK must adopt the UIScene lifecycle or it will not launch. Flutter 3.38 added the migration APIs and the framework has moved in that direction. Delaying only compresses the time available to deal with problems like this one.
Does this break deferred deep linking?
No, provided your deferred deep linking works over HTTP after app start rather than through an iOS lifecycle callback. Flinku's SDK retrieves the match with a network call from match() and does not register for Universal Links, so it is unaffected. Check how your own tooling retrieves the match if you are unsure.
Why does the link work when the app is in the background?
Because scene(_:continue:) is called normally when the app is already running. The bug is specific to cold start, which is why it survives casual testing. Always test with the app fully killed.
Should I revert to the AppDelegate lifecycle?
Reverting buys time but not much. Apple's requirement is not negotiable, so you would be migrating again shortly with the same problem. Handling the cold start case explicitly in scene(_:willConnectTo:options:) is the more durable position.