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.

1. App installed → open it, carrying the result type 2. App not installed → send them to the store 3. Installed just now → don't lose the result they earned

The browser helps with none of them.

1. The Core Constraint — a Failed Scheme Does Nothing

Opening an app by custom scheme is trivial.

window.location.href = 'athlentic://fitness-type?type=' + typeKey;

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.

Mechanism With no app installed Needs fallback Platform Cost ───────────────────────────────────────────────────────────────────────────── Custom scheme Nothing happens yes both none athlentic://… (some browsers warn "cannot open") Universal Link (iOS) The web page opens no iOS domain https://ceanlab.com/… assoc. App Link (Android) The web page opens no Android files Intent URL (Android) Auto-redirects to built in Android none intent://…#Intent;… browser_fallback_url (in the URL) only

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-association on iOS, /.well-known/assetlinks.json on 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 hidden
  • pagehide — 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.

function openInApp() { const typeKey = $('share').dataset.type; if (!typeKey) return; const storeUrl = storeHref(); let leftPage = false; const markLeft = () => { leftPage = true; }; document.addEventListener('visibilitychange', markLeft, {once:true}); window.addEventListener('pagehide', markLeft, {once:true}); setTimeout(() => { document.removeEventListener('visibilitychange', markLeft); window.removeEventListener('pagehide', markLeft); if (!leftPage && document.visibilityState === 'visible') { window.location.href = storeUrl; // assume not installed } }, 1500); window.location.href = 'athlentic://fitness-type?type=' + encodeURIComponent(typeKey); }

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.

Value App installed No app installed ──────────────────────────────────────────────────────────────── 300ms Bounces to store often Store immediately. Good, (launch can't keep up) though it can read as a glitch 800ms Occasional bounce on older devices Barely noticeable 1500ms Almost never bounces Slight "did it freeze?" beat 3000ms Never bounces The button looks broken (safe even on a quick return from the app)

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.

This is an inference, not a determination.

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.

Situation Verdict User sees Severity ────────────────────────────────────────────────────────────────────────── No app · screen off within 1.5s "opened" No store moderate No app · taps a notification "opened" No store moderate Has app · launch takes over 2s "no app" Store over the app bad Has app · accepts the scheme "no app" Store behind the bad prompt too late prompt Has app · normal "opened" The app — No app · normal "no app" The store —

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.

const storeHref = () => /Android/i.test(navigator.userAgent) ? 'https://play.google.com/store/apps/details?id=com.ceanlab.athlentic&pli=1' : 'https://apps.apple.com/kr/app/.../id6760968275';

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.

Approach Recovery Requires User burden ────────────────────────────────────────────────────────────────────── Server deferred deep high a server + device none link fingerprint matching, or a third-party SDK Clipboard medium nothing none (a banner appears) Universal Link high domain association none files (useless before install) Show a short code and low nothing types it in have the app ask for it

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.

// Leave the result URL on the clipboard so first launch can restore the type. try { navigator.clipboard.writeText( location.origin + location.pathname + '?type=' + encodeURIComponent(typeKey) ).catch(() => {}); } catch (error) { /* clipboard unavailable — proceed with the deep link */ }

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 https form, not athlentic://. iOS clipboard URL detection doesn't recognize custom schemes.
  • Don't await it. 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.
The clipboard is a secondary path.

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.

Web navigates to athlentic://fitness-type?type=turtle iOS scheme registration + parses the type parameter Android intent filter + parses the type parameter → one typeKey string that must match in three places

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.

Changing a typeKey requires changing the deep link parsing in iOS and Android too. Changing only the web breaks it silently. Changing the fallback logic requires testing on real iOS and Android devices. A desktop browser does not verify it.

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, visibilitychange never 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.

installed · iOS → app opens, no store redirect installed · Android → app opens, no store redirect not installed · iOS → App Store after 1.5s not installed · Android → Play Store after 1.5s

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.

Environment Has app No app Checked ────────────────────────────────────────────────────────────────────── iOS · Safari app opens App Store yes iOS · Chrome app opens App Store yes iOS · in-app browser prompt, then App Store yes app opens (prompt appears) Android · Chrome app opens Play Store yes Android · Samsung Internet app opens Play Store yes Android · in-app browser varies by Play Store partly device Desktop · any browser n/a App Store n/a

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 visibilitychange and pagehide, 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.