This site has a fitness type quiz. Answer eight questions, get a type, and pick up a matching seven-day routine in the app. The result screen has a single button: "Get this routine in the app."
That button has three jobs.
The browser helps with none of them.
1. The Core Constraint — a Failed Scheme Does Nothing
Opening an app by custom scheme is trivial.
If the app is installed, it opens. If it isn't, nothing happens. No exception, no event, no error callback. The page just sits there and the user concludes the button is broken.
So every fallback is an inference. You can't ask "did the app open?", so you ask "did the user leave this page?" instead.
2. Inferring It From Page Departure
When the app really opens, the browser is pushed to the background, and one of two events fires.
visibilitychange— the document becomes hiddenpagehide— the page leaves the screen
Which one fires varies by device and browser, so we listen for both. Then we throw the scheme, wait briefly, and if we're still here, conclude the app isn't installed.
A few details earn their place.
The timer is armed before the scheme navigation
setTimeout sits above the location.href assignment. Scheme navigation can halt the page synchronously, so registering the timer afterward risks it never being registered at all.
1.5 seconds
Too short and you bounce to the store while the app is still launching. Too long and users without the app stare at nothing. This is where several trials landed. There's no correct value — it moves with device speed.
Check both the flag and the current state
The timeout consults document.visibilityState as well as leftPage. If someone opened the app and came back within 1.5 seconds, the flag is set but the page is visible again — and sending them to the store then would be wrong. We fall back only when both say we never left.
A user who switches apps or locks the screen for unrelated reasons reads as "the app opened." A very slow app launch reads as "not installed." It can't be made exact; the goal is being right on the common paths.
3. The Store URL Splits by Platform
There isn't one fallback destination.
User-Agent branching is generally discouraged, but here the question is which store to send someone to, and there's no real alternative — it isn't the kind of thing feature detection can answer.
Defaulting to the App Store is deliberate too: pressed from a desktop, it at least opens the app's page, which beats a button that appears to do nothing.
4. Installing Loses the Context
This is the genuinely hard part.
The app wasn't installed, so they went to the store, installed it, and opened it for the first time. The app has no idea what result they came from. The trip through the store severed the context. Eight answered questions, gone, and the app opens on a blank screen.
The textbook fix is a server-side deferred deep link — restoring the original link on first launch — which means adding a server to a static site. We took a different route: the clipboard.
On first launch the app checks the clipboard, and if it holds a result URL on our domain, restores the type and offers a "load this result?" card.
Two details here as well.
- Copy the
httpsform, notathlentic://. iOS clipboard URL detection doesn't recognize custom schemes. - Don't
awaitit. Awaiting the clipboard write can break the user gesture context, which may then block the scheme navigation. So it's fire-and-forget, and a failure doesn't stop the deep link.
It fails silently without permission or when the browser blocks it — and the primary flow (open app, else store) has to keep working regardless. That's why the catch is empty: this failure is one you're allowed to ignore.
5. Where Changing Only the Web Breaks It Silently
This feature spans three repositories. The web throws the scheme; iOS and Android parse it. Change only the web and the feature dies without a single error.
Rename a type key — turtle, fox, dolphin — on the web alone and the app receives a value it doesn't recognize and falls back to its default screen. No exception is raised. The routine is simply wrong, and nobody files a bug for that.
So the rules file carries it as its own item.
This is the kind of rule that can't move into a build guard — what it would check lives in other repositories. When you can't automate it, the next best thing is putting it where someone changing that code will see it.
6. A Desktop Browser Doesn't Verify Any of This
The most commonly forgotten point: this logic cannot be tested on a desktop browser.
- There's no app to respond to the custom scheme.
- With no app switch,
visibilitychangenever fires for that reason. - The store link opens, but there's no install flow behind it.
"I clicked it on my laptop and it went to the store" means the fallback always fires — not that the fallback is correct. The case that actually needs checking is the opposite one: with the app installed, does it avoid the store?
That's at least four real-device cases.
7. In Summary
Deep link fallback is hard not because the code is complicated, but because it sits on top of a constraint: there is no way to learn that it failed.
- You can't ask whether the app opened, so infer it from whether the page was left. It's an approximation, not a determination.
- Listen for both
visibilitychangeandpagehide, and consult the flag and the current state together in the timeout. - Arm the timer before the scheme navigation. Reversed, the fallback may never register.
- Never let a secondary path block the primary one. That's why the clipboard write isn't awaited.
- Document that web, iOS, and Android share one value. Changing one side breaks it silently.
If you're building a hand-off button from web to app, design the failure path first. Success is one line; everything else is code for handling failure.