Universal Links vs App Links vs Custom URI Schemes

Last quarter I pulled a client's routing logs after their retargeting numbers cratered. Creative was fine, and the audience was fine. The problem was one line in the tag: someone had shipped raw myapp://product/1234 links straight into a paid social campaign. On Android, taps that landed on users without the app installed hit ERR_UNKNOWN_URL_SCHEME — a full-screen browser error, no fallback, no web page. Every one of those taps was paid for.

Here's the two-sentence version you can lift. In the universal links vs app links comparison, both are HTTPS links the OS verifies against a file you host, and both fall back cleanly to the browser when the app is gone. Custom URI schemes have no verification layer and no graceful fallback, which is exactly how you end up paying for error screens.

Who verifies ownership, and what breaks when the app is gone

Every routing decision on mobile comes down to two questions. Who confirms your app is allowed to handle this link, and what happens when the target app isn't on the device?

Those two questions separate the three mechanisms cleanly. Universal Links and App Links both put a verification file on your server and ask the OS to check it, and when the app is absent, they degrade to a normal web page. Custom schemes skip verification entirely. Any app on the phone can register myapp://, and there's no arbiter deciding who wins a collision. When nothing handles the scheme, the browser doesn't fall back — it errors.

That's the whole comparison. The rest is plumbing, and the plumbing is where afternoons disappear.

The capability matrix at a glance

This is the reference table, and everything below expands on a row.

Universal Links (iOS) App Links (Android) Custom URI Schemes
Config file & path /.well-known/apple-app-site-association (no .json extension) /.well-known/assetlinks.json None
Verification mechanism AASA file + associated-domains entitlement Digital Asset Links, android:autoVerify, SHA-256 cert fingerprint None — any app can claim the scheme
Absent-app fallback Opens same HTTPS URL in Safari Opens same https URL in browser, silently ERR_UNKNOWN_URL_SCHEME or nothing
Who can claim it Only the verified domain owner Only the verified domain owner Anyone
Size / format constraints ≤128 KB uncompressed, HTTPS, no redirects HTTP 200 + application/json, no redirects N/A

Apple's own documentation is blunt about the ownership point: "Unlike custom URL schemes, universal links can't be claimed by other apps, because they use standard HTTP or HTTPS links to your website." That single sentence is why scheme collisions exist on one side of the table and not the other, and it's worth keeping in mind for every row underneath it.

Trace the request. A user taps an HTTPS link, and iOS checks whether any installed app has claimed that domain via its associated-domains entitlement, cross-referenced against the AASA file for that domain. Match, and the app opens directly to the routed content. No match, and Safari loads the URL.

The file lives at https://yourdomain/.well-known/apple-app-site-association, with no .json extension. Apple stated in its WWDC19 session that "this file should be located at HTTPS://your domain name/.well-known/apple-app-site-association. Other paths are deprecated." So are the old signed-file approaches, so don't waste time resurrecting them.

Two constraints trip people up. First, size: Apple's Universal Links documentation specifies that "for apps that run in iOS 9.3.1 and later, the uncompressed size of the apple-app-site-association file must be no greater than 128 KB, regardless of whether the file is signed." Second, no redirects — the file must be served directly.

And there's a wrinkle that broke a lot of debugging workflows. Since iOS 14, devices no longer fetch AASA from your server directly. According to a widely referenced write-up by developer onmyway133, "Apple's Content Delivery Network requests the apple-app-site-association file for your domain within 24 hours. Devices check for updates approximately once per week after app installation." Your server sees the CDN, not the phone, which means the thing you're trying to debug is one layer removed from anything you can watch live.

What the same URL does with no app

Apple describes the absent-app behavior plainly: "When your app isn't installed, tapping a link to your website opens the content in Safari, as users expect." Same URL, working web page, zero error screens.

Here's a minimal AASA payload:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "paths": ["/product/*", "/promo/*"]
      }
    ]
  }
}

The paths array decides which URLs the app claims, and everything outside it goes to Safari by design.

Android's model rhymes with Apple's, but the file and the verification differ. You host a Digital Asset Links file at /.well-known/assetlinks.json, declare android:autoVerify="true" on your intent filter, and the OS crawls the file to confirm your app is authorized for the domain.

The file names three things: the relation, the package, and the certificate fingerprint. Google's Digital Asset Links documentation gives the canonical shape:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.app",
    "sha256_cert_fingerprints": ["hash_of_app_certificate"]
  }
}]

Now the trap that eats a genuinely painful number of setups. The file has to come back correctly to a machine, not a browser. Airbridge's Android App Links setup guide states it flatly: "Your assetlinks.json must return HTTP 200 with Content-Type: application/json. Any redirect (301 or 302) breaks Android's verification crawler, even if your browser handles it fine." You'll load the URL yourself, see valid JSON, and conclude everything's healthy, while the crawler quietly fails on the 301 your CDN added.

Timing changed recently too. On Android 14 and lower, verification happens on app install or update. Android 15 added periodic background re-verification plus dynamic rules defined inside assetlinks.json, and the Android developer documentation warns that "changes can take up to seven days to propagate to all end-user devices due to the system's caching and scheduled re-verification." So a fixed file doesn't mean an instantly fixed fleet.

The silent browser handoff

When verification fails or the app is absent, the https link opens in the browser. No dialog, no error. The user just lands on the web version of the page, which is the behavior you want. That silence is also why broken verification is hard to notice — nothing crashes, and your deep-link conversions just quietly stop happening in-app.

Custom URI schemes, and why they carry no safety net

This is the dangerous one, and the short version follows.

A custom scheme is a string. myapp://. Any app can register it, and the OS keeps no ownership record. Install two apps that both claim myapp:// and behavior is undefined. There's no file to host, no crawler, and nothing to verify.

The failure mode is the part people forget. Tap a scheme link with no app to handle it and the browser doesn't fall back to a web page. It throws ERR_UNKNOWN_URL_SCHEME. A dead end.

This is why intent:// links exist on Android. They wrap a scheme with an explicit escape hatch, S.browser_fallback_url, so that when no app handles the intent the browser opens a real URL instead of erroring. If you must ship a scheme, it belongs inside an intent:// with that fallback set — never naked in an ad.

Working an example through 10,000 taps

Let me put numbers on it. This is my model, not a measured field stat, so treat the inputs as assumptions while I illustrate the mechanics.

Take 10,000 paid taps on cold top-of-funnel traffic. For that kind of audience, assume 70% don't have the app installed, which gives you 7,000 users. That's a defensible starting point for prospecting, not a benchmark.

Route those 10,000 taps three ways. Through Universal Links or App Links, the 3,000 install-holders open in-app to routed content, and the 7,000 without the app land on the equivalent web page. Every tap resolves to something usable, with zero error screens.

Through a naked custom scheme, the 3,000 with the app still route fine. The 7,000 without it hit ERR_UNKNOWN_URL_SCHEME. That's 7,000 of 10,000 taps dumped into a dead end you paid for.

Say those taps cost $0.60 each. The 7,000 broken landings represent $4,200 of spend that produced an error page, and on a $6,000 campaign that's the majority of the budget spent on nothing. Wrap the scheme in an intent:// with S.browser_fallback_url and you recover essentially all of it, because the fallback URL catches the no-app case. Same creative, same audience — one config decision separates a clean funnel from a four-figure leak. The real-world numbers vary, but the direction never does.

A practical decision guide

Ship HTTPS-verified links as your primary path: Universal Links on iOS, App Links on Android. They can't be hijacked by another app, and their fallback is a working web page rather than an error. That combination is why they're the default for anything that touches paid media.

Keep custom schemes only as a legacy internal fallback, and only wrapped in intent:// with an explicit browser fallback URL. Inside your own app, for known-installed states, a scheme is fine. Pointed at cold traffic, it's a liability.

Managing three link types across two operating systems gets fiddly, which is why routers exist — a single short link that resolves the right mechanism per platform. Tools like Kixo resolve all three link types behind one kixo.cc short link. If you're weighing options, our rundown of how routing and fallbacks differ across deep-linking platforms covers the tradeoffs, and the reference architecture for a privacy-first attribution stack shows where link routing sits in the wider measurement picture.

What you still can't measure

Here's where the scars show. You can't confirm verification state per device in real time. On iOS, the CDN caching model means an AASA change isn't instant — the CDN refreshes within about 24 hours and devices check roughly weekly, per that onmyway133 breakdown. You push a fix and then you wait, blind.

Android 15 is worse for certainty. With up-to-seven-day propagation from background re-verification, you can't query whether domain verification is currently passing on a given user's phone. You can check your own device, but you can't check the fleet. So when conversions dip, you're often diagnosing a state you can't directly observe, working backwards from behavior.

I've spent whole afternoons confirming a file was correct while the field stayed broken for days. The plumbing is knowable, but the propagation isn't. Ship the verified links, wrap the schemes, and build your dashboards to survive the lag, because the OS will not tell you the truth on demand.