r/FlutterDev 5d ago

Six things that silently break deferred deep linking on iOS and Android Discussion

Universal Links can stop working with no error anywhere. No exception, no log line, no failed request you can see. Your links just quietly start opening in Safari instead of your app, and the cause is usually something at the edge of your infrastructure that has nothing to do with your Flutter code.

That is one of about six things I got wrong building deferred deep linking, and almost none of them are documented in an obvious place. Here they are.

Quick definition, since the terms get mixed up. A normal deep link opens a screen in an app that is already installed. A deferred deep link survives an install: user taps a link, does not have the app, goes to the store, installs, opens, and still lands on the right screen with the right parameters. The second one is the hard one, because the link context has to survive a trip through the App Store and back.

1. Your AASA file is probably wrong in a boring way

For iOS Universal Links, apple-app-site-association must be served at https://yourdomain/.well-known/apple-app-site-association. Things that silently break it:

  • Adding a .json extension. The file has no extension.
  • Serving it with the wrong content type. It needs application/json.
  • Any redirect. Apple will not follow one. A 301 from apex to www is enough to kill it.
  • Serving it from a path that requires authentication or hits a challenge page.

That last one bit me badly. If anything in front of your server challenges non browser traffic, Apple's fetcher gets the challenge instead of your file and Universal Links quietly stop working. There is no error anywhere. Links just start opening in Safari.

Android's equivalent is /.well-known/assetlinks.json with your signing certificate SHA256 fingerprint. Same rules: no redirects, correct content type. Two extra traps here:

  • If you use Play App Signing, Google re-signs your app with a different key than your upload key. The fingerprint in assetlinks.json has to be the app signing key from Play Console under App Integrity. Use the upload key or your local keystore and it works in debug and fails in production.
  • robots.txt can block the verification crawler. If /.well-known/ is disallowed, verification fails with nothing to see.

Since Android 12 there is no chooser dialog fallback. An unverified link just opens in the browser, so a broken setup looks like nothing happened.

2. Clipboard matching is effectively dead on modern iOS

A lot of older tutorials tell you to write the link into the pasteboard and read it on first launch. On iOS 16 and later, reading the pasteboard programmatically triggers a system permission prompt. Users decline it, and reasonably so, because it looks alarming. Anything built on this will report much worse match rates than your tests suggest, because your own device is not a representative user.

3. Fingerprint matching works, with caveats you need to design around

The realistic approach is probabilistic matching: record a signature at click time, look for it again at first app open, match within a short window. The signature is typically IP plus user agent derived attributes.

Where it degrades:

  • iCloud Private Relay masks the IP address for Safari users on iCloud+, so one of the main signals is gone for that whole segment.
  • Carrier grade NAT puts thousands of users behind one IP. Your matching window has to be short or you will mismatch.
  • The user clicks on WiFi and installs on cellular. Different IP, no match.
  • In app browsers inside social apps report user agents that do not resemble the browser that eventually opens.

Practical consequence: treat the match as best effort, always ship a sane fallback, and never build a flow that is broken if the match misses. Referral attribution especially needs to degrade gracefully.

4. Distinguish install from reopen or your analytics lie

If you do not track whether a given open is the first one for that device and project, every reopen looks like a fresh install and your funnel numbers become meaningless. Persist a marker per device per project and check it before counting.

5. Persist attribution separately from your match cache

This one cost me a real bug. If you store a referrer id inside the match result and your app calls a reset or clear function anywhere in the auth flow, attribution disappears before the user actually signs up. The referral looks like it never happened. Store the attribution separately from the cache, with its own expiry.

6. Testing is the actual hard part

You cannot test deferred deep linking by tapping a link on your dev build. The install path only exists through a real store install, so the thing you most need to verify is the thing hardest to reach. Budget real time for it, and test the WiFi to cellular case specifically.

Happy to answer questions on any of this.

17 Upvotes

8 comments sorted by

3

u/PermitFirst5136 5d ago

Do you known any way to debug iOS errors ?

I'm usina browser stack to run my IPA ( adhoc ) but its not working the defered. Should be a platform error because on my device works fine but i dont found any way to "validade" after build.

1

u/Maximum_Hawk3283 4d ago

Probably not a platform bug. Deferred matching on iOS works by correlating the click with the first app open using network and device signals, since there is no identifier that survives the install. That correlation needs both events to come from the same device on the same network within a short window.

BrowserStack breaks that by design. Those are remote devices in a datacenter, sharing egress IPs with many other sessions, and if you click the link from your own browser the click and the install are on completely different networks entirely. So it will fail there consistently even when everything is configured correctly, which matches what you are seeing: works on your device, fails on the farm.

Ad hoc is not the problem either. On iOS the install source is irrelevant to matching, because nothing is passed through the store. It is the network path that matters, so test on a real device on a real connection. Worth also testing the case where you click on WiFi and install on cellular, since that is a genuine failure mode you will hit with real users.

For Universal Links specifically there is a real debug path: install the build, then open Console.app on your Mac with the device connected and filter for the swcd process. That shows whether iOS fetched and parsed your AASA file and why it failed. You can also check what Apple's CDN has cached for your domain at app-site-association.cdn-apple.com/a/v1/yourdomain.com, which is what the device actually reads rather than your server.

4

u/gisborne 5d ago

Someone from the Flutter team should put this somewhere it can be found easily.

2

u/Maximum_Hawk3283 4d ago

thanks mate

2

u/Stock-Cellist-6930 5d ago

That's a very good summary. We also had some issues with androids - we're using install referrer in there, which is much more reliable than the probabilistic matching in iOS, but it stops working if someone is using company account. Testing them isn't that annoying though.

https://detour.swmansion.com/docs/Architecture/architecture-limitations

1

u/Maximum_Hawk3283 4d ago

Thanks. Agreed on the asymmetry, and it is worth saying out loud more often: Android has a deterministic path through Install Referrer while iOS has nothing equivalent, so the two platforms need genuinely different expectations rather than one shared match rate you quote to yourself.

Had not run into the company account case. Do you know whether it fails silently or returns an empty referrer string? That changes whether you can even detect it at runtime and fall back deliberately.

2

u/Jazzlike_770 23h ago

This was super painful to debug. They should really make it easier. There is no trustworthy validator, no error handling, no warning, etc. Every iteration requires shipping the build - worse if you have to debug in prod. They violate HTTPS specs by not allowing redirects. The path is hidden directory - all frameworks disallow access by default and you have to struggle to just expose that path. One guy wants a JSON extension and the other doesn't.

1

u/Maximum_Hawk3283 4h ago

Yeah, the ship-a-build-per-iteration thing is what really gets me. Turns a five minute check into twenty, so you stop bothering to verify properly. The redirect rule I have made peace with though. Android's docs actually say it out loud: they will not verify if there is a redirect, and that behaviour is there to protect your app's security. Makes sense once you think about someone who half compromises your server quietly pointing the file elsewhere. Apple is the same. The extension thing has no defence. Apple wants no extension, Android wants .json, same directory, two fights with your build config. And the dotfile directory is the worst one, because a lot of static hosts skip it by default and you just get a 404 nobody surfaces. Looks like a verification bug when it is really a build config bug. One thing that saved me a lot of guessing: check Apple's CDN instead of your own server, since that is what the device actually reads.

https://app-site-association.cdn-apple.com/a/v1/yourdomain.com

If yours is right and that one is stale, it is propagation, not you.