If you have spent even a single sprint wrestling with deep link routing in Kotlin Multiplatform, you already know the pain. Two platforms, two link ecosystems, and a surprising amount of duplicated logic that slowly drifts apart. The easiest way to fix deep link routing in KMP apps for iOS & Android is not to build another bridge or a clever platform-specific utility. It is to stop treating the two platforms as separate routing universes and instead move the entire problem into shared code. The missing piece is one shared resolver with cancellation tokens, designed to unify Firebase Dynamic Links and Universal Links without letting stale navigation win.
The KMP Deep Link Dilemma
At first glance, deep links look like a platform concern. On iOS, Universal Links arrive through the AppDelegate or SwiftUI’s onOpenURL. On Android, Firebase Dynamic Links land in the LauncherActivity or via onNewIntent. Most KMP projects respond by implementing routing twice: once for iOS and once for Android. It works, until it doesn’t. Marketing changes a query parameter, a product manager renames a path segment, or a new campaign suddenly relies on a link that behaves differently on each OS.
The deeper issue is timing. A user might tap a link while the app is cold, warm, or already sitting on a complex navigation graph. The OS delivers the payload at slightly different moments on each platform. Android’s PendingDynamicLinkData is not the same shape as iOS’s NSUserActivity. When you write two handlers, you are also writing two definitions of “correct” — and they will not stay in sync.
A Fresh Wrinkle in 2026: Firebase Dynamic Links Is Going Away
In case this year needed another migration, Google has moved Firebase Dynamic Links toward deprecation. That makes the old “keep the two sides coordinated” strategy even more fragile. Trying to match Universal Links to a service that is actively being retired is the wrong target. Instead, your resolver should extract the one meaningful thing from every incoming link: a normalized navigation intent. That abstraction immediately gives you flexibility to swap Firebase Dynamic Links for Android App Links, a custom domain, or a direct intent-based path when the time comes.
Treat both Apple’s Universal Links and Firebase’s payloads as raw input. Normalize early, route once, and keep the platform entry points as thin as possible.
The Shared Resolver Pattern
In commonMain, define a DeepLinkResolver that owns the current destination. It accepts one unified LinkPayload type, parses it, validates it, and maps it to a route your navigation layer already understands. The two platform implementations are then responsible only for converting their native payloads into that shared type.
A pragmatic way to build this is with Kotlin Coroutines and Flow. You expose a StateFlow<DeepLinkRoute?> for the current route and a MutableSharedFlow for incoming links so the resolver can process them sequentially without blocking the UI thread.
class DeepLinkResolver(private val scope: CoroutineScope) {
private val _route = MutableStateFlow<DeepLinkRoute?>(null)
val route: StateFlow<DeepLinkRoute?> = _route.asStateFlow()
private var resolutionJob: Job? = null
fun submit(payload: LinkPayload) {
// Cancel any in-flight resolution so a stale link never wins
resolutionJob?.cancel()
resolutionJob = scope.launch {
val result = parse(payload)
.onFailure { log(it) }
.getOrNull()
if (isActive) _route.value = result
}
}
private fun parse(payload: LinkPayload): Result<DeepLinkRoute?> = runCatching {
when {
payload.path.startsWith("/offers/") -> DeepLinkRoute.Offer(payload.path.removePrefix("/offers/"))
payload.path == "/profile" -> DeepLinkRoute.Profile
else -> null
}
}
}
Notice what is missing: platform-specific logic, Firebase imports, and UIKit references. The resolver only knows about your domain, your routes, and the LinkPayload you define in shared code.
Cancellation Tokens: Preventing Stale Route Hijacks
Naive shared routing has a hidden race condition. Suppose a user taps an offer link at the same moment the app is processing an earlier deep link from a push notification. If you simply assign the route when parsing completes, the older link can overwrite the newer one. That is not just annoying; it can take the user to the wrong screen and break a critical onboarding flow.
Cancellation tokens solve this. In the sample above, the Job acts as the token. Before every new resolution, you cancel the previous Job. If the old link is still in the middle of parsing, network validation, or a deferred check, it gets cancelled and its result is ignored. The isActive guard ensures the route only updates for the most recent submission.
This pattern is lightweight, uses standard coroutines, and is easy to unit test. You do not need a custom token class or a separate concurrency framework. A Job is a token, and Kotlin’s cancellation mechanics give you the safety you need for free.
Wiring the Platform Entry Points
iOS: Universal Links
On the iOS side, your AppDelegate receives Universal Link invocations through application(_:continue:restorationHandler:). Extract userActivity.webpageURL, convert it into your shared LinkPayload, and pass it to the resolver. If the app is cold, instantiate the resolver first, observe its route Flow, and then submit the initial payload. If the app is warm, just submit.
Android: Firebase Dynamic Links
On Android, you handle the initial link via FirebaseDynamicLinks.getDynamicLink(intent) and the warm path in Activity.onNewIntent(). Convert the dynamic link URL into the same LinkPayload and call resolver.submit(payload). After the Firebase Dynamic Links shutdown, this entry point can be swapped for Android App Links or a custom URI scheme without touching the shared resolver.
Both handlers become roughly ten lines of code. The rest of the logic, including validation and route mapping, stays in common code where it belongs.
Testing and Debugging the Resolver
Because the resolver lives in commonMain, you can unit test it with kotlinx-coroutines-test. Cover the obvious cases first: a valid offer link maps to the offer screen, an unknown path maps to a fallback route, and a malformed URL produces no route at all. Then test the part people often forget — cancellation behavior.
Fire two submit calls quickly with different payloads. Assert that the route Flow only ever emits the second result. This single test will save you from a whole class of production bugs that show up as random mid-session jumps to the wrong screen.
For debugging, log the raw payload and the resolved route with a common logging framework like Napier. Since the resolver is centralized, you get a consistent audit trail for every link that enters your app, regardless of which platform delivered it.
Conclusion
Deep link routing in KMP apps is not really about URLs — it is about timing, state, and preventing platform-specific logic from drifting. By moving to one shared resolver with cancellation tokens, you can unify Firebase Dynamic Links and Universal Links into a single flow that is fast, predictable, and ready for whatever link system arrives next. The resolver becomes the single source of truth, and each platform becomes nothing more than a messenger.
