Deep Links That Survive Cold Start
Deep links break on cold start because the intent arrives before the app is ready, and warm and cold take different code paths. Treating the link as state rather than an event, synthesising a back stack, surviving an auth redirect, and the adb command that is the only real test.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Deep links work warm and break cold because the intent arrives before the app is ready and
because warm (onNewIntent) and cold (onCreate) are two code paths, only one of which anybody tests. Fix
it by treating the link as state, not an event: park the destination in a StateFlow, consume it exactly
once when the app reaches a ready state you define (session resolved, flags loaded). Then synthesise a back
stack so Back goes up rather than out, keep the destination across an auth redirect, and test with
adb shell am force-stop before every am start — a deep link tested on a warm app is untested.
The bug report
"The link in our order-confirmation email just opens the home screen."
It reproduces for support and not for you, because you tap links with the app already in the background. Cold start is the normal case for a link that arrives by email, SMS or notification — it's a link from outside your app, sent to someone who wasn't using it.
There are four distinct failures hiding under that one report. They need four different fixes, and teams usually find one and ship it as the fix.
Failure 1: two code paths, one of them untested
// Cold: process created, intent in onCreate
override fun onCreate(savedInstanceState: Bundle?) { … }
// Warm with singleTask/singleTop: existing
// instance, intent in onNewIntent
override fun onNewIntent(intent: Intent) { … }
If those two paths call different code, one of them is wrong and you won't know which. Make it structurally impossible:
class MainActivity : ComponentActivity() {
override fun onCreate(saved: Bundle?) {
super.onCreate(saved)
handleIntent(intent)
setContent { App() }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // easy to forget; getIntent() goes stale
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val route = DeepLinkParser.parse(intent.data) ?: return
deepLinkHolder.submit(route) // one entry point
}
}
That setIntent(intent) line matters more than it looks: without it, anything later that reads
getIntent() — including a config change — sees the original launch intent, not the new one.
Failure 2: the race you can't see on a fast device
This is the real one.
At cold start the intent is available in microseconds. Your app is not ready in microseconds. Meanwhile several things are racing to navigate:
- The nav host composes with its start destination.
- Session restore finishes at ~200ms and decides to send the user to Home or to Login.
- A remote-config fetch resolves at ~400ms and picks between the old and new home screen.
- Your splash's
keepOnScreenConditionreleases, and the first thing behind it navigates.
The deep link navigates first because it's synchronous. Then the session restore navigates on top of it. On your Pixel this window is 30ms and the ordering happens to work out. On a mid-tier device with a cold page cache it's 800ms and it doesn't.
Fixing it by ordering is a losing game — you'll be re-fixing it every time someone adds an initialisation step. Fix it by changing the shape.
Deep link as state, not an event
An event fires once, at the worst possible moment, into a UI that doesn't exist yet. State waits until it's observed.
@Singleton
class DeepLinkHolder @Inject constructor() {
private val _pending = MutableStateFlow<Route?>(null)
fun submit(route: Route) { _pending.value = route }
fun consume() { _pending.value = null }
val pending: StateFlow<Route?> = _pending.asStateFlow()
}
class MainViewModel @Inject constructor(
holder: DeepLinkHolder,
session: SessionRepository,
flags: FeatureFlags,
) : ViewModel() {
// "Ready" is app-specific. Define it explicitly.
private val appReady = combine(
session.state.map { it !is Restoring },
flags.loaded,
) { a, b -> a && b }
val navigateTo: StateFlow<Route?> =
combine(holder.pending, appReady) { route, ready ->
route.takeIf { ready }
}.stateIn(viewModelScope, WhileSubscribed(5_000), null)
}
val route by viewModel.navigateTo.collectAsStateWithLifecycle()
LaunchedEffect(route) {
route?.let {
navController.navigateDeepLink(it)
viewModel.onDeepLinkConsumed() // exactly once
}
}
The link now survives every startup navigation that would have clobbered it, because it isn't spent until
the app says it's ready. Adding a new initialisation step later doesn't reintroduce the bug — it just adds a
term to appReady.
Consume exactly once. If you don't clear it, a configuration change re-navigates and the user can't leave the screen. That symptom — "the app keeps bouncing me back" — is always an unconsumed deep link.
Failure 3: the back stack that exits your app
The user lands on order detail from an email, presses Back, and is on their home screen. From their point of view your app is broken; from yours, the stack had one entry.
Synthesise the hierarchy the user would have walked:
fun NavController.navigateDeepLink(route: Route) {
when (route) {
is Route.OrderDetail -> {
navigate(Home) // parent first
navigate(Orders)
navigate(OrderDetail(route.orderId))
}
is Route.Home -> navigate(Home) {
popUpTo(graph.id) { inclusive = true }
}
}
}
Two rules that keep this sane: only synthesise real parents (don't invent a screen the user could never otherwise reach), and keep it to two or three levels. Rebuilding a six-deep stack from a link means the URL structure and the navigation structure have drifted apart, which is the actual problem.
Failure 4: the auth redirect that eats the destination
Link to a gated screen, get sent to login, sign in — and land on Home. The destination was thrown away because the code stored whether the user was gated instead of where they were going.
Since the deep link is already state, the fix falls out: don't consume it at the login boundary.
// Login doesn't clear the pending route.
// It just isn't "ready" yet.
private val appReady = combine(
session.state,
flags.loaded,
) { s, f -> s is Authenticated && f }
Sign-in flips appReady, the pending route survives, and the user lands where the link pointed. One
subtlety worth handling: a link that doesn't need auth shouldn't be blocked behind login. Give routes an
requiresAuth flag and gate on it per route rather than globally.
App Links: the failure that never reaches your code
If your https:// link opens the browser instead of the app, none of the above is involved — verification
failed.
# What the system actually thinks
adb shell pm get-app-links com.your.app
# Re-run verification during development
adb shell pm verify-app-links --re-verify com.your.app
Checklist: android:autoVerify="true" on the intent filter, assetlinks.json served at
https://domain/.well-known/assetlinks.json over HTTPS with no redirect, correct content-type, and
the SHA-256 fingerprint of the release signing key — the Play App Signing key, not your upload key. That
last one is the single most common cause, and it only manifests in production, which is a bad place to learn.
Test it the way it actually fails
The only test that counts starts with a force-stop:
adb shell am force-stop com.your.app
adb shell am start -W -a android.intent.action.VIEW \
-d "https://app.example.com/order/42" com.your.app
-W waits and prints the launch time, which is a free startup regression check. Run it on the slowest
device you support, and once with the device under load — adb shell am start in a loop elsewhere, or
simply a debug build with a delay(1500) injected into session restore. Artificially slowing startup is the
most reliable way to expose the race in Failure 2.
Then put it in CI as an instrumentation test per critical link — order detail, promo, notification target, password reset. A parameterised test over a list of URLs is fifty lines and catches the whole class of regression before support does.
The rule
A deep link is state that arrives early, not an event to be handled immediately. Park it, define what "ready" means for your app, consume it once when you get there, and give the user a back stack that goes up instead of out. And force-stop before every test — the warm path is the one that already works.