intent:// links look forgiving. You add S.browser_fallback_url, test it once from your own phone, watch the app open, and ship it. Then the campaign runs and app-open rate on Android sits exactly where it would if the link had gone straight to a browser tab. No errors. No crashes in the console. The link did exactly what you told it to do — the problem is what you told it to do.
What an intent URL actually says
An Android intent link takes this shape:
intent://host/path#Intent;scheme=https;package=<package>;S.browser_fallback_url=<url>;end
Each segment is a separate instruction to Android, not decoration:
| Segment | What it tells Android |
|---|---|
intent://host/path | the target, expressed as an opaque intent instead of a plain URL |
scheme=https | which scheme the receiving app should resolve the target with |
package=<package> | pin the intent to exactly one app; omit it and Android may show a chooser, or nothing |
S.browser_fallback_url=<url> | where the visitor goes if package isn’t installed to catch the intent |
end | closes the intent block |
Read on its own, that looks like a complete instruction: try the app, fall back to the browser. For one package, it is. It stops being one the moment you try more than one.
The single-package case is forgiving — that’s what hides the bug
Most people’s first intent link targets exactly one app: one package, one fallback, done. It works, it ships, and it teaches the wrong lesson, because the failure mode this article is about only exists once a second candidate enters the chain.
Trying several packages in order isn’t an edge case. A link into Telegram may need to check the Play build and Telegram X in sequence. A link into WhatsApp may need to check the regular app and the Business app. Each candidate gets its own intent:// block, fired in order until one of them catches. The instinct is to keep the same S.browser_fallback_url you used on the single-package version and just repeat the block for each package. That instinct is exactly what breaks the chain.
Why the first fallback kills every step after it
Put a fallback URL on the first block, and you’ve already decided what happens when that first package is missing: Chrome sends the visitor straight to S.browser_fallback_url, in a browser tab, before the second intent:// block ever runs. It doesn’t matter that the second package is sitting on the device, installed and ready. It never gets asked, because the chain already ended.
✗ Wrong — a fallback on step one ends the chain early
intent://…#Intent;scheme=https;package=com.whatsapp;S.browser_fallback_url=https://example.com;end
intent://…#Intent;scheme=https;package=com.whatsapp.w4b;end
✓ Right — no step carries a fallback, including the last one
intent://…#Intent;scheme=https;package=com.whatsapp;end
intent://…#Intent;scheme=https;package=com.whatsapp.w4b;end
This is the counter-intuitive part, and it’s worth sitting with: the correct chain has no fallback anywhere inside it, not even on the final candidate. Attaching one to the last package would look correct in isolation — that step really is the last chance for an app to catch the intent — but it trains you to think of the fallback as belonging to a step at all. It doesn’t. It belongs to the chain as a whole, applied exactly once, after every candidate has been tried and none of them caught. And that decision has to be made by your own page, not by any single intent:// block.
Why market:// can’t bail you out
The obvious workaround is to point the first step’s fallback at the Play Store listing for that exact package, so a missing app at least sends the visitor somewhere relevant instead of a generic web page. That doesn’t work either: S.browser_fallback_url accepts only http and https values. A market:// value placed there is dropped by Android — the store doesn’t fail to open, the field never accepts that scheme to begin with. Whatever you put in S.browser_fallback_url has to be a real http or https URL, which puts you right back in the trap above: any URL in that field ends the chain on the first missing package, store link or not.
The gesture rule turns a failed jump into a phantom “not installed”
There’s a second condition stacked on top of the ordering problem, and it’s the one that makes the first one hard to diagnose from outside. Chrome requires a user gesture to act on an intent:// navigation — a tap, not a redirect that fires on page load and not a timer going off on its own. Fire the exact same link without a gesture behind it, and Chrome doesn’t attempt to launch the app at all. It goes straight to S.browser_fallback_url, as if the package weren’t there.
From outside the click — in a reporting dashboard, in a screen recording of the redirect, in anything short of instrumenting the page itself — that outcome is indistinguishable from the app genuinely not being installed. Both cases land on the same fallback URL by the same path. There’s no signal on the receiving end that tells you “Chrome refused because there was no gesture” apart from “Android tried and the package really wasn’t there.” You only get to tell them apart by knowing, on your own side, which of your redirect steps actually fired from a tap.
This is why a chain that tests perfectly by hand can still fail in production. A manual test is a sequence of taps: you tap the link, you tap through whatever comes next, and the gesture requirement is satisfied at every step without you ever noticing it’s a requirement. The moment any part of that sequence gets automated on your end — the whole ordered attempt has to run from that same originating tap — the app open can disappear with nothing in your logs to explain why.
Where the fallback actually belongs
Put the rules together and the design isn’t really a judgment call anymore:
| Rule | Consequence |
|---|---|
No intent:// step carries S.browser_fallback_url | a missing package moves to the next candidate instead of exiting to the browser |
S.browser_fallback_url only accepts http/https | there’s no scheme trick that lets a mid-chain fallback double as a store link |
| Chrome needs a user gesture for the navigation | the ordered attempt has to trace back to one tap, not a follow-on redirect |
| The chain has to end somewhere | your own page, not any intent:// block, decides when every candidate is spent |
That last row is the actual job: build the ordered list of intent:// attempts with no fallback field anywhere inside it, and let your own page apply the store URL — https://play.google.com/store/apps/details?id=<package> on Android — only once the full chain has run and nothing caught. This is precisely the routing problem trackers built for app traffic exist to solve. DarkCore’s redirect chains run the package list server-side and apply the store fallback exactly once, after the last candidate, instead of leaving a fallback stitched into a step where it silently ends the chain before it’s finished.
What this looks like in your funnel
Neither failure mode announces itself. A visitor whose first-choice package is missing and hits a fallback on step one looks, in your funnel, exactly like a visitor whose device genuinely has none of the candidate apps installed. A visitor whose redirect lost the user gesture partway through looks the same again. All three land on the same fallback URL, at the same point in the flow, with the same absence of an error. What moves is one aggregate number — app-open rate — and it moves down, evenly, across a population that includes plenty of people with the app sitting on their home screen.
That’s what makes this bug specifically hard to catch by looking at dashboards: there’s no segment to isolate. “App not installed” isn’t a group of visitors you can filter for, because the field doesn’t exist — every one of these outcomes writes the identical fallback event. The only way to separate ordering failures from gesture failures from genuine absence is to test each condition on purpose: fire the chain by hand with every candidate app installed, confirm each one catches in order, then repeat with the chain triggered the way it actually fires in production — same page, same trigger, same redirect path — and see whether the tap survives to the last step.
Before you ship the next chain
- Strip
S.browser_fallback_urlfrom every step in a multi-package chain, including the last one. The chain owns the fallback decision, not any individualintent://block. - Never write
market://intoS.browser_fallback_url. Android drops it silently — only http and https values are accepted there, so a store-scheme fallback isn’t a workaround, it’s a value the field never reads. - Fire the whole chain from one user gesture. Anything that hands off mid-chain without a fresh tap can lose the app open with nothing in your logs to explain why it happened.
- Treat “landed on the fallback URL” as ambiguous by default. It means either “no package in the chain caught it” or “the gesture requirement wasn’t met,” and only instrumentation on your own page — not the fallback event itself — can tell you which one actually happened.
- Apply the store fallback once, on your own page, after the ordered list of candidates has fully run. That’s the only point in the flow where “nothing caught this” is actually true.