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.
Why a custom scheme at all
There are three ways to hand someone from web to app. We looked at all three and settled on this one.
On the table, Universal Links and App Links win outright — failure isn't even a concept there. The app intercepts the URL when it's installed; when it isn't, that same URL just opens as a web page. No fallback code required.
The cost sits elsewhere.
- You have to host association files.
/.well-known/apple-app-site-associationon iOS,/.well-known/assetlinks.jsonon Android. They're bound to the app's team ID and signing fingerprint, which means a change to app signing becomes a change to the website. - The OS caches them. Edits take time to reach devices, and the state in between is hard to observe.
- One URL now has two behaviors. The same address may open in the app or in the browser, so "I actually want the web page" needs a separate route.
Universal Links, in other words, trade a fallback problem for a deployment-configuration problem. With one entry point and the apps living in separate repositories, hosting files coupled to app signing looked more expensive than twenty lines of fallback. Add entry points and that calculation flips.
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.
The way to pick a value is to ask which failure is worse. Sending someone who already has the app to the store means telling an existing user to install the thing they have — conspicuously bad. Making someone without the app wait 1.5 seconds is just slightly slow. That asymmetry is what pushed the value long.
Changing the button's state while waiting matters more than the number does. A second and a half is short; a second and a half with no feedback is long.
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.
The misreads come in four shapes
Knowing which misreads remain is what lets you interpret a bug report later.
The middle two are the ones that generate reports. Both send someone who has the app to the store, and both have the same cause: no visibility change arrived inside 1.5 seconds.
The first two are silent. Nobody files "it didn't send me to the store," so we don't know how often they happen. Finding out would mean stitching clicks to installs — which is a conversation about adding an analytics dependency to a static site.
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.
There were four options.
The third one doesn't fit this problem. Universal Links intercept a link when the app is already there, so they do nothing for the path that runs through the store. Plenty of write-ups bundle the two together, but they solve different problems.
The first is the textbook answer. It works by stitching a click to an install through a device fingerprint — IP, screen size, OS version — which means a server, and a wider scope of personal data. That's adding a server to a static site.
So we took what was left: 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.
Where, exactly, matters. The test we used for what belongs in a rules file was "will the person editing this file encounter that sentence?" The deep link rule fails that test in two of the three repositories no matter where it lives. So it went into the web rules file and into a comment directly above the function. That's duplication, and here duplication is correct — people editing code don't open the rules file.
The better answer is to generate the key list from one place all three repositories share. With eight keys we haven't paid that cost yet. More types, and the day arrives.
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.
In practice that multiplies by browser. Chrome, Firefox, and other browsers on iOS all sit on WebKit, but scheme handling and visibility events are not identical across them. In-app browsers — arriving from a link tapped inside a messaging or social app — differ most often.
The third row shaped the design. In-app browsers sometimes raise an "open in app?" confirmation prompt, and if the user accepts it slowly, 1.5 seconds elapse and they land in the store — because visibility doesn't change while the prompt is up. The current implementation can't prevent that. It's a hole we know about and left open.
7. Common Questions
Would Universal Links make all of this go away?
The fallback problem, yes. In exchange you get association files that must stay in sync with app signing, and OS caching that makes the state right after a deploy hard to observe. And the post-install context problem in section 4 remains either way — Universal Links are about the case where the app is already there.
Why not use blur or focus?
blur tracks window focus, not page visibility. It fires when someone clicks the address bar, and some browsers don't fire it on an app switch at all. visibilitychange observes document visibility directly, which is what we actually want. pagehide is listened for alongside it because visibilitychange sometimes doesn't arrive on iOS.
What about the old hidden-iframe trick?
Setting an invisible iframe's src to the scheme used to be standard. Modern browsers largely block it, and it conflicts with the policy of not opening schemes without a user gesture. location.href is the current answer.
Could we drop the timeout and use visibility events alone?
No. When there's no app, no event fires at all. Detecting the absence of an event means measuring time. The timeout is the definition of "nothing happened."
Isn't writing a URL to the clipboard intrusive?
iOS shows a banner when an app reads the clipboard, so the read has to happen at a moment that makes sense — once, on first launch, and only surfacing a card when what it found is a result URL on our domain. Otherwise it's discarded silently. When the reason for the read is visible on screen, the banner reads as an explanation rather than a surprise.
Can any of this be covered by automated tests?
Parts of it. Store URL selection, the order the timer is armed in, and the fact that a clipboard failure still lets the scheme navigation proceed are all unit-testable. What isn't testable is "does the app actually open" — which is the feature. That's why real-device checks don't come off the list.
8. 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.
- Write down the misreads you're left with. If you can't remove them, you at least need to recognize them in a bug report.
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.