
Deep links are best-effort, so plan for the miss
On this page
I wanted a small app to hand off to a larger companion app. Tap a button here, land in the right screen over there. Easy, until you remember the companion app might not be installed at all.
That one uncertainty shaped the whole design, and it turned out to be a gift. Get it wrong and the harm is mundane and infuriating: a user taps your button and nothing happens, a dead end with no explanation and nowhere to go next.
Assume nothing is installed#
The handoff used the target app’s custom URL scheme, the something:// link that
opens a specific screen. But before opening it, the code asked the platform a
question: can you open this URL?
if canOpenURL(targetScheme) -> open the deep linkelse -> open an https URL insteadThe fallback is an ordinary https link, the kind that resolves in a browser or sends the user to the app store. So the button always does something useful. If the companion app is there, you jump straight into it. If it is not, you land somewhere that makes sense instead of tapping a dead link.
That “can you open this” check did a second job for free. It is also a cheap “is the other app installed?” probe. The same answer that decides which URL to open can vary the UI: show a “continue in the app” button when it is present, an “install to continue” one when it is not. One question, two uses.
A deep link is a request, not a guarantee. Pair it with an https fallback and the worst case is a browser tab, not a dead button.
Change content without shipping a build#
The second lesson lived nearby, in the same instinct: assume the untrusted thing might let you down, and handle it at the boundary.
I needed to change some content and feature flags without cutting an app-store release. The approach was a remote JSON document. The app fetched it with a timeout, so a slow network could not hang the launch, then ran the result through a strict hand-written validator before trusting a single field of it.
Valid config was cached on device with a staleness check, so the app stayed fast and worked offline. And on any failure, a bad fetch, a timeout, a document that did not pass validation, it fell back to a default baked into the build.
So the config could always fail safe. Fresh config when the network gave good data, cached config when it did not, baked-in config when everything else went wrong.
Validated config beats code over the air#
It is tempting to go further and push actual code over the air, so you can change behaviour without a release. I have kept away from that. Shipping new code outside the store review is a large surface with a large blast radius when it goes wrong.
Server-driven, validated JSON is the lower-risk version of the same wish. You get to change often, but the only thing crossing the wire is data, and every field of it meets a validator at the door. The one rule that makes it safe is that the config is untrusted input. Validate it at the boundary, exactly like anything else arriving from a network, and never let an unvalidated field reach the rest of the app.
The lesson#
Deep links between apps are best-effort. Never assume the target is installed, so pair every custom-scheme link with an https fallback, and reuse the openable check as a presence probe to shape the UI.
For content that changes often, server-driven JSON with an embedded default beats an over-the-air code pipeline. Fetch with a timeout, validate strictly, cache with a staleness check, and fall back to the baked-in default. The whole thing holds together because you treat the config as untrusted and check it at the boundary.


