Flutter's UIScene Migration Is Silently Breaking iOS Deep Links
I believe quite a few people have encountered this recently: after upgrading a Flutter project, iOS Universal Links can successfully launch the app. The Associated Domains and AASA files all seem fine, but once inside the app, app_links fails to receive the link, getInitialLink() also returns null, and the Router ends up normally going to the home page.
The entire process has no exceptions or errors; it just fails silently.
But in reality, this only started happening after Flutter 3.38, especially by Flutter 3.41, when UIScene officially became the default lifecycle solution for iOS apps. One of the reasons is that old plugins and legacy native code are still only listening to AppDelegate, causing some URL, OAuth, notification, and lifecycle events to start being silently lost.
This is because a Universal Link, from the user's tap to the Flutter page navigation, actually goes through two completely independent processes.
The first part happens entirely at the iOS system level. The user taps: https://example.com/product/123
iOS checks:
- AASA file
- Associated Domains
- Bundle ID
- Team ID
- entitlement
- Domain configuration
Once these conditions are met, iOS launches the app. This indicates a successful system-level match, but the work of actually delivering the URL into Flutter is still ahead. The whole chain looks more like this:
If any layer in this process breaks, it can ultimately manifest as:
So troubleshooting this type of problem is very troublesome. Sometimes you might spend a lot of time checking AASA, GoRouter, Navigator, redirect rules, etc., but in reality, the URL might have already been lost at the iOS Native lifecycle layer.
So what exactly did UIScene change? Mainly, in the past, the vast majority of iOS Flutter plugins relied on UIApplicationDelegate. For example, Universal Links typically listened on:
application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: ...
)
Custom URL Schemes commonly used:
application(
_ app: UIApplication,
open url: URL,
options: ...
)
Flutter plugins also often registered like this:
registrar.addApplicationDelegate(instance)
This approach has worked for many years, so the Flutter plugin ecosystem has accumulated a large number of implementations based on the AppDelegate lifecycle. After switching to UIScene, URL-related callbacks enter a different lifecycle.
Custom Schemes correspond to:
scene(
_ scene: UIScene,
openURLContexts URLContexts: Set<UIOpenURLContext>
)
Universal Links correspond to:
scene(
_ scene: UIScene,
continue userActivity: NSUserActivity
)
The mapping provided in Flutter's official migration documentation is very clear:
That is, once an App adopts UIScene, some UI lifecycle events previously received through AppDelegate will no longer be called along the old path. This creates a compatibility issue. If a Flutter plugin only registers:
registrar.addApplicationDelegate(instance)
but does not register:
registrar.addSceneDelegate(instance)
then after the App has completed the UIScene migration, the plugin may completely fail to receive the URL. The system knows the link should open your app, and the app is indeed opened, but only the plugin doesn't know what the user tapped.
This problem existed in app_links 6.x, and then app_links 7.0.0 specifically addressed the iOS Scene lifecycle changes in Flutter 3.38, adding support for UISceneDelegate.
However, the minimum Flutter version was also raised to 3.38.1. This means plugin developers face a problem: whether to support only the new version with a breaking change, or to maintain compatibility with both old and new versions.
Flutter provides new lifecycle interfaces for plugin authors, including:
FlutterSceneLifeCycleDelegate
FlutterPluginSceneLifeCycleDelegate
FlutterSceneLifeCycleProvider
A new registration method was also added during the plugin registration phase:
registrar.addSceneDelegate(instance)
Therefore, plugins that need to be compatible with both legacy and UIScene projects typically register both:
registrar.addApplicationDelegate(instance)
registrar.addSceneDelegate(instance)
So if your plugin or native code has anything similar to:
- Universal Link
- Custom Scheme
- OAuth Callback
- Login callback
- Shortcut
- Notification
- Third-party SDK URL callback
- App foreground/background state
then you must have addSceneDelegate, because UIScene is now mandatory.
Of course, if it were just this, it would be fine. The core issue is that Flutter's built-in Deep Link handler creates a second layer of conflict. For example, FlutterDeepLinkingEnabled. Actually, around Flutter 3.27, the built-in Deep Link handler was enabled by default. If a project uses Flutter Router to handle Deep Links directly, this mechanism is very convenient.
The problem is that many projects already use third-party plugins, such as:
- app_links
- uni_links
- flutter_branch_sdk
In this case, two Deep Link handlers might be working simultaneously. So if you are using a third-party Deep Link plugin, you actually need to disable Flutter's default Deep Link handler, for example in Info.plist:
<key>FlutterDeepLinkingEnabled</key>
<false/>
Otherwise, it can lead to even more bizarre phenomena, like:
So why might a Universal Link redirect back to Safari?
Actually, this can be seen directly from the Flutter iOS Embedder implementation. The Flutter AppDelegate first asks the registered plugins if they have handled continueUserActivity. If no plugin consumes it, Flutter continues trying to pass the Deep Link to the Framework.
When the Framework also ultimately fails to handle it, there is a fallback path for Universal Links: handing the URL back to iOS, and then the browser might be opened. If the webpage then contains:
myapp://xxx
or secondary redirect logic like Branch, AppsFlyer, Firebase, or JavaScript redirects, it might launch the app again. Ultimately, what the user sees is:
This looks like an AASA misconfiguration or abnormal Safari behavior, but the actual problem is that the Deep Link handler did not correctly consume the URL.
The main issue doesn't end there. Even if the plugin adapts to UIScene, the Dart layer might still lose the Deep Link because the listener is set up too late. Surprising, right? Because the startup logic of many Flutter projects is becoming increasingly complex, for example:
If the App is cold-started via a Deep Link, the URL likely arrives very early. Then, executing:
uriLinkStream.listen(...)
after all initializations are complete means the event window might have already passed. Therefore, the app_links official documentation now explicitly recommends that AppLinks should be created as early as possible to capture the first link. A more robust approach is to treat Deep Links as a fundamental event input source for the App, requiring listening as soon as possible after application startup:
late final AppLinks appLinks;
void initDeepLinks() {
appLinks = AppLinks();
appLinks.uriLinkStream.listen((uri) {
handleOrQueue(uri);
});
}
If the Router, Auth, or database is not ready yet, just store the link first. This makes scenarios like cold start, warm start, and login recovery more unified.
There is another more subtle problem: the URL is received, but the Startup is never Ready. For example, await FirebaseMessaging.instance.getInitialMessage(); might not return under certain circumstances, and the startup flow looks something like:
The result then becomes as shown below, and at this point, the logs might even already show the URL.
At this time, looking at Universal Links, app_links, or the Router makes it very hard to find the real problem, because the link transmission chain is completely normal. What's actually stuck is the entire startup state machine.
This design is actually very common in current Flutter projects. The main() or Splash initialization logic in many projects looks like:
await initFirebase();
await initRemoteConfig();
await initMessaging();
await initDatabase();
await initAuth();
await initAnalytics();
await initFeatureFlags();
await initRouter();
If just one SDK on this chain has a callback that doesn't return, a network timeout, or an abnormal permission state, all subsequent functions stop together. Therefore, for third-party SDKs that must be waited for, it's best to set a timeout and fallback:
try {
await FirebaseMessaging.instance
.getInitialMessage()
.timeout(const Duration(seconds: 2));
} catch (_) {
// fallback
} finally {
markStartupReady();
drainPendingLinks();
}
So, in reality, iOS Deep Links are already quite troublesome on their own. The system is famously a very long event chain. Add Flutter to it, and it looks something like:
Then, if any step from step 2 to step 10 goes wrong, you'll see the result not being obtained. Depending on the problem, you need to investigate different directions:
If step 2 fails, you need to check:
AASA
Associated Domains
Bundle ID
Team ID
If steps 4 or 5 fail, you need to focus on checking:
UIScene
FlutterSceneDelegate
Scene lifecycle
If step 6 fails, you need to check:
Plugin version
addSceneDelegate
FlutterSceneLifeCycleDelegate
If step 7 fails, you need to check:
Platform Channel
EventChannel
Listener initialization timing
If steps 8 to 10 fail, the problem has entered the app's own state management:
Pending Link
Auth
Startup Ready
Router
Navigation
So, for DeepLink problems, you need to first confirm which layer the URL actually reached. Troubleshooting is time-consuming and laborious. Combined with SDK and system conflicts, plus initialization deadlocks, this type of problem is indeed very frustrating.