Opening the app from a link, and what happens when it is not installed


A link on the web should open the app on the exact screen that the link points to. Sometimes the app is not installed. In that case the link should do something better than showing the person a page that only says the app exists.

The second part is the hard one. The browser will not tell you whether an app is installed, and it should not: a page that could list your installed apps would be a fingerprinting tool.

Here is an example that runs in production. Open it on a phone and not on a laptop: Pavel Durov's channel in the Nicegram catalogue. The Open button on that page is the subject of this article.

Nicegram is a Telegram client, so the fallback here goes to Telegram itself. If the app is installed, the phone opens Nicegram. If the app is not installed, the same tap opens Telegram on the web, and it carries the channel you came from. There is one URL behind that button and there are two results. None of my code chose between those two results.

The guess this replaced

Before the server was involved in any of this, the page tried to work it out alone. I inherited a version that was built from this Stack Overflow answer. It was copied so closely that even the variable name was the same:

js
var now = new Date().valueOf();
setTimeout(function () {
    if (new Date().valueOf() - now > 100) return;
    window.location = "https://play.google.com/store/apps/";
}, 25);
window.location = "appname://";

That answer is from April 2018 and its score is minus one. That is where code that ran in production for years actually came from. I prefer to write this down rather than suggest that the code came from a specification.

The idea works like this. The page navigates to a custom URL scheme. A custom URL scheme is an address like appname:// that an app registers for itself when it is installed. Then the page starts a timer. If the app takes the navigation, the browser suspends the page, which means the page stops running its code. So when the timer finally runs, it runs late and does nothing. If the page is still running and the timer fires on schedule, the code assumes there is no app and goes to the store page instead.

The timer is twenty-five milliseconds and the threshold is one hundred milliseconds. Both numbers are guesses about how fast a phone switches between apps. Somebody measured them on one device, one time. No browser ever agreed to respect either number. A busy phone can need more than one hundred milliseconds even when the app is installed, and then the timer decides the app is not there.

Look at what the elapsed-time check really reads. The check does not ask whether the app exists. The check asks whether the page stopped running, and it treats that as the same question. The scheme fires, focus leaves for the app, the page freezes, the timer runs late, and nothing happens. That is the happy path, and it is built on a promise that no browser ever made.

So it does not always go that way. Some browsers keep the timer running while the app is starting. Then the check passes, the page navigates to the store, and the app opens as well. Both things happen. The person is now inside the app, and whatever they do there, the browser is sitting on a page that the person never chose, waiting for them to come back to it.

It gets worse when the person comes back. Imagine they return to the browser one minute later. Far more than one hundred milliseconds have passed, so the same code now reads that delay as proof that the app took the link. The story that the page tells about what happened depends on how long somebody spent somewhere else.

Then the platforms ended the argument. The Android answer with the highest score starts with a line that its own author went back and added years later: UPDATE: Google broke this. On the Apple side, an answer from January 2017 says it plainly. The behaviour changed in iOS 9.2 in order to push developers onto universal links. None of this was ever a contract that you could look up. It was only behaviour, and behaviour moved.

Give the decision away

Universal links on iOS and App Links on Android turn this around. A universal link, and an App Link on Android, is an ordinary https:// link that an app can claim as its own. At install time the app declares which URLs on your domain belong to it. After that the operating system owns the decision. The operating system is the one component in this story that actually knows what is installed, because the operating system did the installing.

The setup is not what this article is about. There is a file that you host on your domain, and there is a declaration that the app ships with. The platform's own documentation covers both of them properly. The part that nobody writes down is what happens next.

One path, not the whole site

The claim of the app does not have to cover your whole domain. Point the claim at a single path, and keep the real destination in a query parameter:

ts
const UNIVERSAL_LINK_ORIGIN = 'https://www.example.com'

export const linkToApp = (target: string) =>
  `${UNIVERSAL_LINK_ORIGIN}/deeplink?url=${encodeURIComponent(target)}`

If the claim covers every path, then your pricing page and your support articles both become candidates for a handoff. A handoff means that the operating system opens the app instead of the page. Then you will spend a year explaining why a help page opened the app. A single path also gives you exactly one place on the server to put the fallback, which is where this article is going.

The link has to leave the site to work

This is the rule that makes people think their setup is broken when the setup is fine.

A universal link does not hand off when the person clicks it on a page that is already on the same domain. The browser reads that click as ordinary navigation inside the site that the browser is already showing, so the browser keeps the navigation. Your link is correct, your association is correct, and nothing happens.

So the link has to point at a host that is technically different. The page sits on example.com and the link goes to www.example.com. For a reader these are the same site. For the check inside the browser they are different hosts. This is not a decision about canonical URLs, and it is not cosmetic.

www is a convention here and not a requirement. The rule is about hosts, and any host you control will do as long as it is not the one serving the page. The deep link vendor Branch documents it, quoted in an answer from 2016, as a rule about crossing domains rather than about a particular name: a link on one host pointing at another host opens the app, and a link pointing at the same host does not. So the second host can be a dedicated subdomain that exists for nothing else, and what it serves when the app does not take the link is the subject of the rest of this article.

The same source gives two more limits, and both of them are worth knowing before you spend a day looking for a bug you do not have. A link pasted into the address bar never opens the app. And a navigation started from JavaScript does not open the app either, unless it happens as part of a real user action. If you build the URL in code, the URL still has to be followed by a person clicking something.

A request that arrives is the answer

I got here by turning the old code around. That code put the watching on the web side: fire the scheme, watch the clock, guess from whether the page was still alive. I wanted the opposite. Nothing of mine should watch anything, and the request should reach my server only when there is no app to take it. That moves the decision to the only place that can actually make it, which is the operating system.

The browser is the program that makes the request. The browser also knows about the claim of the app, because the operating system recorded that claim. So the browser tries the app first. If the app is not installed, or if the app declines the handoff, the browser has nothing left to do except the thing that browsers do, which is to ask the server.

This means that a request landing on my own server carries information. That request is the answer to the question I could not ask directly, and the answer is handed over by the only party that has it.

I had no proof of that ordering when I wrote the branch. It was an assumption about how the chain is wired, and I built on the assumption anyway. The assumption held.

The rest follows from there. When the claim fires, the request never leaves the phone. The operating system hands the URL to the app, and your server never hears about it. Silence is the success case. When the claim does not fire, the request arrives, and a request arriving at /deeplink is the operating system telling you that it had nothing to hand this URL to. You never detect anything. You get told, and you get told by absence.

That turns the whole problem into an ordinary server branch. The code below is Next.js 15, and in that framework the file has to be called middleware.ts and has to sit at the root of the project. Nothing about the idea depends on the framework: any server that can answer one path before the router does will do.

middleware.ts
import { NextResponse, type NextRequest } from 'next/server'

const ALLOWED_ORIGINS = ['https://chat.example.net']

function resolveTarget(raw: string | null): string | null {
  if (!raw) return null

  try {
    const parsed = new URL(raw)

    return ALLOWED_ORIGINS.includes(parsed.origin) ? parsed.href : null
  } catch {
    return null
  }
}

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname !== '/deeplink') return NextResponse.next()

  const target = resolveTarget(request.nextUrl.searchParams.get('url'))

  return target
    ? NextResponse.redirect(new URL(target))
    : NextResponse.rewrite(new URL('/download', request.url))
}

Note which of the two branches is a redirect and which is a rewrite, because they are not interchangeable here. A redirect tells the browser to go and request a different URL, and the address bar changes to that URL. A rewrite serves the content of another path from the same request, and the address bar does not change. The Telegram link has to be a redirect, because the destination is somebody else's site. The download page is a rewrite, so the person stays on /deeplink and a reload does not bounce them anywhere.

The allowlist in that code is not tidiness. The ?url= parameter is written by whoever shares the link, so redirecting to whatever it says publishes an open redirect on your own domain: a URL of yours that sends people wherever an attacker picks. Phishing campaigns are built out of exactly that, because the recognisable part of the address is your domain. So compare the parsed origin against a list of exact origins. A substring check on the hostname passes https://chat.example.net.evil.test, and you will not notice until someone else does.

Absence is strong evidence and it is not proof. Every condition from earlier in this article is also a way for the request to arrive from a phone that does have the app: the link pointing at the host the page is already on, the link pasted into the address bar, the navigation started by code rather than by a person. Your server sees the same request in all of those cases as it sees from somebody who has never installed anything. So pick a fallback that stays tolerable when you are wrong.

Where to send someone who does not have the app

For a long time the fallback was the download page, with the store buttons on it. That is the obvious answer. The person has no app, so offer them the app.

I changed it in July 2025, and the reasoning has held up better than I expected. Someone who taps a link to a conversation wants the conversation. A store page asks that person to install something first, and then to find their way back to a link they no longer have. Most people stop at the first step. The few people who finish arrive at an app that has no idea what link sent them.

So the fallback sends them to the same conversation on the platform where that conversation already lives. They get what the link promised, on the web, immediately.

The store page kept a job. The branch goes to the store page when the link carries no usable destination at all. A store page is a reasonable fallback when there is nothing at the other end of the link. A store page is a bad fallback when there is something there.

Notice what this does when the guess is wrong. Somebody who does have the app gets sent to the same conversation on the web, which is where that person was going anyway. So being wrong costs almost nothing. That is luck, but it is the kind of luck you can arrange on purpose.

The chain, in order

A page on example.com wants to point at the conversation https://chat.example.net/c/123, so it builds this:

https://www.example.com/deeplink?url=https%3A%2F%2Fchat.example.net%2Fc%2F123

Walked from one end to the other:

  1. Your code builds that URL on www.example.com, a host the page is not currently on, which is the whole reason www is in there.
  2. The tap goes to the operating system, which checks the URL against the claims it recorded when apps were installed.
  3. If a claim matches, the app opens and reads the same ?url= parameter to work out which screen to show. Your server sees nothing.
  4. If no claim matches, the request reaches /deeplink, your allowlist accepts https://chat.example.net, and the person is redirected to https://chat.example.net/c/123.

Only the first step and the last step are yours. The two middle steps belong to the operating system and to the app. This design is worth copying because it stops pretending otherwise.

The question that matters is not how to find out whether an app is installed. You cannot find that out, the platforms have decided that you should not, and every technique that claims otherwise is guessing. The question is what the person gets when the app is not there. That is a product decision in a technical costume, and it gets made by whoever writes the fallback branch, usually while that person is only trying to get a link to work.