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.

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.

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.

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.

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.

// 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.

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

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 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.

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.