All writing

Why iOS Deferred Deep Linking Was a Pain Compared to Android

Android had an official install referrer. iOS had limitations, unreliable device matching, and a lot more work. Here is how I ended up with an IP-based referral workaround that is useful in production, even with its limitations.

Last year, I was implementing a referral system in a Flutter app. The requirement looked simple: someone shares a link, another person installs the app through it, and we know who referred them when they sign up.

On Android, this was fairly straightforward. On iOS, it was a pain. There is no general-purpose App Store install-referrer API to pass our referral code through installation the way Google Play does. I spent much more time working around that limitation than writing the referral feature itself.

Firebase Dynamic Links used to support deferred deep linking on iOS too. Its SDK used the pasteboard to improve post-install matching; disabling that gave a weak match at best. So this behavior was possible, but Firebase also needed extra mechanisms to make it work. Its iOS documentation explains that flow.

Firebase later deprecated Dynamic Links for both Android and iOS, with the shutdown date set to August 25, 2025. Firebase cited changes across the web and mobile ecosystem that made reliable post-install behavior harder to provide. It would be too simple to say iOS alone caused the shutdown. The deprecation FAQ gives the reasons.

Google Play's Install Referrer API was already around long before that: it launched in 2017. It wasn't a new Android-only replacement created by Firebase. Android still has that official handoff; iOS doesn't have the same option for this referral use case.

I ended up trying IP address, screen size, pixel ratio, and language to connect the browser visit with the app. The final workaround uses only IP matching. It has been useful in production, but it comes with limitations that the Android flow doesn't have.

This post is about that experience. The snippets are small parts of the idea, not a complete integration guide.

The gap between clicking and installing

A normal deep link can open a screen in an app that is already installed. A deferred deep link has one more problem to solve: remembering the original link while the user goes through installation.

For our referral flow, the journey was:

  1. A user opens a referral link.
  2. If the app is not installed, they reach our website.
  3. The website records the click and redirects them to the store.
  4. They install and open the app.
  5. The app tries to recover the referral code before signup.

The difficult part was connecting step 3 with step 5.

The browser had the referral code. The newly installed app did not. Just opening the App Store was not enough to carry that information across.

Why Android was easier

For installs through Google Play, there is an official Install Referrer API. It lets the app retrieve referral information associated with the installation.

Our website adds the referral code to the Play Store URL's referrer parameter. In Flutter, we read it using the android_play_install_referrer package.

Inside an async method on Android, the basic part looks like this:

final details = await AndroidPlayInstallReferrer.installReferrer;
final referralCode = details.installReferrer ?? '';

In our flow, the value is the referral code. Other apps may encode structured data there and need to parse it differently. There is also error handling around this in the app.

Google Play provides the connection between the store visit and the installed app. We don't need to guess whether two requests came from the same phone.

This is specifically a Google Play feature, though. It is not something every Android installation method provides.

iOS has Universal Links. They can open an installed app, and when the app is missing, the link opens the website. But that doesn't automatically replay the original URL after installation. Apple's Universal Links documentation explains the installed-app and website behavior.

For this referral flow, there wasn't a general-purpose App Store equivalent of the Play Install Referrer API that would hand our custom referral code to the newly installed app.

So I tried keeping the referral on our backend and finding it again when the app opened.

The idea sounded reasonable at first: if the browser and app are on the same phone, maybe they can send enough similar information to match the two requests.

That assumption was where most of the work started.

Trying to match the same device

The implementation had three parts: the Next.js website, the Flutter app, and the backend.

The website saved the referral code along with the IP address, browser user agent, platform, click time, a session token, and a fingerprint made from screen dimensions and language. Then it redirected the user to the App Store.

On iOS, the app sent its own screen dimensions, locale, and a device description to the verification endpoint when the login screen loaded.

I initially used logical dimensions in Flutter. In January 2025, I changed that to physical dimensions and updated the website to multiply its screen measurements by the pixel ratio.

On the web, I used screen.availWidth and screen.availHeight, multiplied by window.devicePixelRatio. Flutter used PlatformDispatcher.instance.views.first.physicalSize. Both sides added the language or locale, producing a value like 1179x2556:en_IN.

Screen size was not the same measurement

The browser's screen.availHeight describes available screen space in CSS pixels. Multiplying by devicePixelRatio converts the units, but doesn't make that space identical to the Flutter app's view. The APIs measure different things. MDN explains available screen height and pixel ratio.

Flutter's physicalSize describes the app view. It can change with the view configuration, and it can even be zero if read too early during startup. It is not a permanent hardware identifier. Flutter documents those details here.

Looking back at the current code, there is another inconsistency: the main referral page uses available dimensions multiplied by pixel ratio, while a separate download button uses screen.width and screen.height directly.

So even our web entry points don't always produce the same kind of value. And even if they did, many phones share the same resolution.

Language and user agent didn't solve it either

Changing en-IN to en_IN only fixes a formatting difference. The browser's preferred language and the device's default locale are separate values; changing the separator doesn't guarantee they agree. They are also shared by many users. See navigator.language and Flutter's locale API.

The user agent had a more direct problem in our implementation. The website saved the browser's user agent, but the app built a string from the iOS device name and system version. Those are different strings, even on the same phone.

A session token could identify a saved click, but the app still needed a way to receive that token after installation. Saving it on the website didn't solve the handoff by itself.

I had been thinking about this as a device-identification problem. Really, I needed a piece of referral data that both sides could access reliably.

What the current implementation actually uses

In March 2025, I commented out the fingerprint, session-token, and user-agent conditions in the backend matcher.

The current matching rule is much simpler: find the latest iOS referral click with the same IP address.

But where does that IP come from? This changed as I worked on the implementation.

First, I fetched the public IP in Flutter

The earlier app called ipify to get its public IP, then sent it as clientIP to our verification endpoint. A small version of that code is:

import 'package:http/http.dart' as http;

Future<String?> getPublicIp() async {
  try {
    final response = await http
        .get(Uri.parse('https://api.ipify.org'))
        .timeout(const Duration(seconds: 5));

    return response.statusCode == 200 ? response.body.trim() : null;
  } catch (_) {
    return null;
  }
}

This is the old approach, with a timeout and status check added for the example. It gets the public address seen by that service, not the phone's local Wi-Fi address. It also adds another network request and dependency.

I removed this from the app in February 2026. The app now calls our verification API, and the backend reads the IP from that request.

On the web, Next.js reads the visitor's IP

The browser posts to a Next.js route before the store redirect. That route reads the IP from request headers supplied by the hosting or reverse-proxy layer. The relevant part, simplified from our route, is:

import { headers } from 'next/headers';

async function getVisitorIp() {
  const requestHeaders = await headers();
  const rawIp =
    requestHeaders.get('x-real-ip') ||
    requestHeaders.get('x-forwarded-for')?.split(',')[0] ||
    '';

  return rawIp.replace(/^::ffff:/, '').trim();
}

Next.js then forwards this address to the backend in X-Forwarded-For, along with the referral data. Without forwarding the visitor's address, the backend could end up recording the web server's address instead.

So both servers are involved: Next.js extracts the web visitor's IP, and the backend saves it against the click.

The backend handles the app request too

When Flutter calls the verification endpoint, the backend uses the same IP extraction helper it uses when recording clicks. Its priority is the first X-Forwarded-For address, then X-Real-IP, then the request or socket address.

This smaller Express example shows that order for ordinary string headers; the actual helper also handles header arrays:

import type { Request } from 'express';

function getClientIp(req: Request) {
  const rawIp =
    req.get('x-forwarded-for')?.split(',')[0] ||
    req.get('x-real-ip') ||
    req.ip ||
    req.socket.remoteAddress ||
    '';

  return rawIp.replace(/^::ffff:/, '').trim();
}

These header examples assume a trusted proxy setup that controls forwarding headers. The headers themselves are not proof of an address. If the server cannot determine an IP, it should skip matching rather than treat an empty value as a shared identity.

Finding the referral again

Reduced to the relevant query, it looks like this:

const click = await prisma.referralClick.findFirst({
  where: {
    platform: 'iOS',
    ipAddress: clientIp,
  },
  orderBy: { clickedAt: 'desc' },
});

Here, clientIp is the address resolved from the app's request. If it matches a saved iOS click, we have a candidate referral.

Screen dimensions and language are still collected. They simply do not participate in the current match. I had to check the query again myself to confirm this.

If a click is found, the backend returns the referral code and referrer's name. Flutter displays the referral and includes the code in the signup request. The current backend stores it against the new profile and handles referral credit during OTP verification.

There is also a daily cleanup job for clicks older than seven days. The matching query itself has no age condition, so cleanup is not the same as a strict seven-day matching window.

Where IP matching falls short

An IP address identifies a network connection much better than it identifies a person.

Two people using the same office Wi-Fi can have the same public IP. If both click different referral links, choosing the latest click can assign the wrong referral.

The reverse can also happen. Someone opens the link on Wi-Fi and launches the app later on mobile data. Same person, different IP, no match.

iCloud Private Relay adds another complication. Safari can reach the website through a temporary IP address. That means I cannot assume the website will see the same address as a later app request. Apple describes how Private Relay changes the address visible to websites.

Even with these limitations, this workaround has been useful in production. When the browser visit and app request arrive from the same public IP, it can recover the referral without making the user type a code. That was the practical benefit for our flow.

But the result is still a probable match. I don't have a measured success rate to put here, and a matching IP doesn't prove that this installation came from that referral link.

There is also a platform restriction beyond accuracy. Apple says developers must not derive device data to uniquely identify a device, including through browser, device, or network signals. Reducing the signals to IP alone should not be treated as a way around that rule. This is a record of what I built, not a recommendation to copy fingerprinting into a new app. Apple's privacy FAQ covers this explicitly.

A useful workaround, with limits

I still think the workaround was useful for the problem I had. It gave us a working referral flow on iOS, even though the platform didn't provide the same install-referrer support as Google Play.

The next improvement I would make is a fallback: let the user enter a referral code, or ask them to reopen the invitation after installing so the app receives the link directly.

That adds a small step, but it also gives the user a way to recover when automatic attribution fails. These are changes I would consider, not features already provided by the IP matcher.

The main thing I learned was that adding more device fields doesn't necessarily make a match better. Some fields don't agree between the browser and app. Others agree across thousands of different phones.

This is why iOS felt much harder for this feature. On Android, Google Play carried the referral through installation. On iOS, my backend had to infer the connection later. I found something that works in production, but I still have to accept the cases where that guess fails.

Thanks for reading. Keep building, keep learning.

More notes from the build