A visitor taps your link aimed at Instagram or Telegram, and the app never opens. Chrome opens instead, straight into the page you built the redirect specifically to route around. The click registered. The dashboard shows a normal click. The destination is a browser tab.
This isn’t one failure mode with one fix. It’s a chain of independent decisions made by the browser, by Android, and by the destination app, and losing at any single link in that chain produces the identical symptom: browser instead of app. Understanding the chain is the only way to tell which link broke.
A plain https link doesn’t know it’s supposed to open an app
A https:// URL is just a link. Whatever renders it decides what to do with it, and nothing about the https scheme itself carries an instruction to hand the click to a native app. That instruction has to be added deliberately. An https:// link on its own renders as a web page every time, regardless of whether the destination app is installed.
intent:// is the instruction that’s missing
The form that adds it:
intent://host/path#Intent;scheme=https;package=<package>;end
This names the package Android should hand the click to, while keeping the underlying https scheme in the intent for verification. When the named package is installed and registered for that content, Android routes the click to it directly, with no browser stop in between.
The part that trips people up is the fallback. An intent:// URL can carry S.browser_fallback_url for the case where the target package isn’t there, but that field accepts only http and https values. Point it at a market:// URL instead, and Android drops it. The fallback you thought you configured never fires the way the syntax suggests it should.
One app is rarely one package
Fallback logic gets complicated the moment you try more than one package, and for several major apps you have to. The same app ships under different package names depending on where the install came from:
| App | Packages |
|---|---|
| Telegram | org.telegram.messenger (Play Store), org.telegram.messenger.web (APK from telegram.org), org.thunderdog.challegram (Telegram X) |
com.whatsapp, com.whatsapp.w4b (Business) | |
| TikTok | com.zhiliaoapp.musically, com.ss.android.ugc.trill |
com.instagram.android | |
| Shopee | one package per country: com.shopee.id, .br, .vn, .th, .ph, .tw, .my, .sg, .mx, .cl, .ar |
| Amazon | com.amazon.mShop.android.shopping globally, except India: in.amazon.mShop.android.shopping |
The Telegram split catches the most people by surprise. The APK Telegram serves from its own website carries a different package id than the Play Store build, org.telegram.messenger.web, confirmed by reading it straight out of that APK’s manifest. Target only the Play package in your intent chain, and every visitor who installed Telegram from telegram.org directly is invisible to your routing, indistinguishable from an app that isn’t installed at all.
Amazon is a reminder to verify rather than assume: its Japanese store uses the same global package as everywhere else. A jp.amazon.mShop.android package shows up on APK mirror sites, but it isn’t a live Play listing. Building a chain around it targets nothing real.
None of this is optional detail for a routing setup that only targets one build. It matters the moment you try to cover an app’s full install base, because the packages above are not interchangeable candidates competing for the same visitor. A visitor with the telegram.org build installed and a visitor with the Play build installed are two different people from Android’s point of view, each requiring their own named package in the chain before either one reaches the app.
The fallback rule almost everyone gets backward
Once you’re chaining multiple packages (Play build, then regional build, then a last resort), the instinct is to attach a fallback URL to the first attempt too, just in case.
That’s exactly backward. If the first candidate carries a fallback and its package isn’t installed, the visitor gets pushed into the browser before the second candidate is ever tried. The chain never reaches the package that would have worked. No step in a multi-package chain may carry a fallback URL, including the last one. The fallback belongs after every candidate is spent, applied by your own landing page once you know none of them fired.
Chrome won’t attempt the jump without a tap
Even a correctly built intent:// URL, aimed at the right package with no premature fallback, depends on how it’s triggered. Chrome requires a user gesture to act on an intent:// navigation. Fire it automatically, on page load, from a script, from a redirect chain with no click in the middle, and Chrome does not attempt to launch the app at all. It goes straight to S.browser_fallback_url.
From outside the page that outcome is indistinguishable from the app not being installed. Your logs, your fallback landing, everything downstream looks the same whether the app genuinely isn’t there or Chrome simply refused to try because nothing the visitor did triggered the jump. This is the detail worth designing the whole flow around: an automatic attempt isn’t unreliable in the sense of working most of the time. It’s a step that never runs. The tap is not optional polish; it’s the precondition for the entire mechanism.
It’s also the detail QA tends to miss, because manual testing is built almost entirely out of taps. A tester opening the link by hand satisfies the gesture requirement every single time, so a flow that fires the jump automatically partway through a redirect chain can pass every manual test and still fail for real traffic the moment a step in production doesn’t originate from a direct tap.
A webview is a different room, not a smaller Chrome
Not every click lands in Chrome. A link opened inside another app’s own in-app browser, the webview Instagram, Facebook, or TikTok wrap around outbound links, is a separate rendering environment. The gesture requirement above is documented for Chrome specifically. What an in-app webview does when it receives the same intent:// URL isn’t governed by the same documentation. A routing setup validated only in Chrome is being tested in one room out of several the traffic actually arrives through.
Proof that a successful jump doesn’t guarantee a happy visitor
Even when every piece above is correct, the destination app can still hand the visitor straight back to a browser. Tested on a real Android 15 device (Pixel 8, Chrome 131):
An Instagram intent link, intent://www.instagram.com/instagram/#Intent;scheme=https;package=com.instagram.android;end, resolved cleanly. Android handed it to Instagram’s own com.instagram.url.UrlHandlerActivity, and the app opened directly on the destination profile and stayed there.
A TikTok intent link, built the same way, also resolved correctly. Android delivered it to com.ss.android.ugc.aweme.deeplink.AppLinkHandlerV2, exactly as designed. The system log showed the task created with the deep link attached, then closing with numActivities=0, and the visitor back in the browser, on the original web page. TikTok was logged out and had nothing to render for that link. The routing worked. The app declined the content.
A successful jump and a visitor who ends up in the browser anyway are not mutually exclusive, and nothing on the sending side can prevent it. If your metric is whether the OS handed the click to the app, that’s yours to control. If your metric is whether the visitor stayed in the app, it depends on a login state and a rendering decision made inside code you don’t own.
What’s controllable, and what isn’t
| Layer | Who decides | Can you control it |
|---|---|---|
| Which package receives the click | Your intent:// construction | Yes |
| Whether the fallback fires prematurely | Your fallback placement, last step only | Yes |
| Whether the jump is attempted at all | Chrome’s gesture requirement | Yes, trigger it from a real tap |
| Chrome vs. an in-app webview | Where the visitor tapped the link | No |
| Whether the target package is installed | The visitor’s device | No |
| What the app shows once opened | The app’s own login and render state | No |
The first three rows are engineering. Build the chain correctly, keep every fallback off every candidate step but the last, and never fire the jump automatically. A stream built this way, and DarkCore’s redirect logic applies the same fallback-last rule and per-app package ordering, gets every routable visitor to the point of the jump. The last three rows aren’t a routing problem at all. No fallback URL, no retry logic, and no cleverer intent:// string changes what happens once the OS has already handed the click to the app.
FAQ
Does adding more packages to the chain fix a TikTok-style bounce-back?
No. That bounce happened after Android correctly handed the click to TikTok. The app had nothing to render because it was logged out. More packages help a visitor on a different app build reach the right one. They don’t change what a receiving app does once it has the click.
If a visitor lands on the fallback, does that mean the app isn’t installed?
Not necessarily. It means either the app isn’t installed, or the jump was never attempted because it fired without a user gesture. Both produce the same fallback landing, and nothing in your logs distinguishes them.
Does the fallback ordering rule apply if I only target one package?
The risk it guards against is specific to chains with more than one candidate. With a single package, there’s nothing to prematurely push the visitor away from, since a fallback on that one step only fires once that package has already been ruled out. The rule matters the moment you add a second candidate to cover a different build of the same app.