Back to Blog
engineering

How we removed fingerprinting from our deep-link attribution

June 3, 2026
How we removed fingerprinting from our deep-link attribution

How we removed fingerprinting from our deep-link attribution

A user taps a shared Fridgify link on Twitter, gets sent to the Play Store, installs the app, opens it, and lands on the exact recipe they were sent. That last hop — recipe → store → install → recipe again — is called deferred deep linking, and for a long time we were holding it together with a fingerprint heuristic. Last week we ripped the fingerprint out. Here's what we replaced it with and why the replacement is shorter, more honest, and easier to reason about.

The problem

The old flow hashed IP + User-Agent + Accept-Language + timestamp into a SHA256 fingerprint at click time, stored it server-side with the deep-link params, and matched against the SDK's fingerprint on first app launch.

Two things were wrong with this.

First, fingerprinting works until it doesn't. Mobile carriers rotate IPs, OS updates change User-Agent strings, "private relay" features punch holes through both. Our match rate was fine on Android but visibly leakier on iOS, and we had no way to prove an install had come from a given click — only that two requests looked similar.

Second, the heuristic was our only path. When fingerprinting failed, the user landed on a generic home screen and we silently lost the attribution.

While opening this up we also found a dumber problem: on slow networks the web redirect page would await two analytics calls before redirecting. If either hung, the user sat on a spinner forever — "Opening…" stuck on screen, never resolving.

Why it happened

The fingerprint design was an honest workaround. Mobile platforms don't give web pages a clean way to hand a token to a freshly-installed app, and iOS has nothing equivalent to Android's Play Install Referrer. The original code did what every deferred-deep-link library did circa 2016 — hash whatever entropy you can see on both sides and hope the intersection is unique.

The spinner bug had a simpler cause: the analytics calls used await fetch(...) with no timeout, so any backend slowness propagated straight to the user. visibilitychange listeners that should have shown a fallback button weren't being cleaned up, so under certain timings the button never appeared.

What we tried first

For the redirect bug, the naive instinct was to put a timeout in front of the analytics calls and call it a day. We did add AbortController with a 1500 ms budget — but the real fix was conceptual: the redirect shouldn't depend on analytics at all. Logging a click is a side effect; navigating to the store is the contract. We rewrote the calls as fetch(..., { keepalive: true }) fire-and-forget, with the AbortController only as a belt-and-suspenders backup.

For attribution, we considered tightening the fingerprint window or adding more entropy. That would have helped on the margin and made the privacy story worse. Instead we did two completely separate things on the two platforms.

The fix

Android: deterministic via Play Install Referrer. When the user lands on the web redirect for an Android device, we now generate a clickId server-side (a UUID) and attach it to the Play Store URL as part of the install referrer string:

// apps/web/src/utils/referrer.ts
export function withPlayReferrer(playUrl: string, clickId: string): string {
  const url = new URL(playUrl);
  const existing = url.searchParams.get("referrer") ?? "";
  const merged = existing
    ? `${existing}&eodin_cid=${clickId}`
    : `eodin_cid=${clickId}`;
  url.searchParams.set("referrer", merged);
  return url.toString();
}

The Play Store preserves the referrer query string across install. On first launch, the SDK reads the install referrer, extracts eodin_cid, and asks the backend for the deferred parameters keyed on that exact clickId. The match is deterministic — same string in, same string out — and we mark it as matchType: "clickId" in the response so we can audit attribution quality from logs.

The backend side is a clickId String? @unique column on the DeferredParam table plus a tiny parser:

function parseClickIdFromReferrer(raw: string | null): string | null {
  if (!raw) return null;
  const params = new URLSearchParams(raw);
  return params.get("eodin_cid");
}

We also made the insert idempotent — if the same clickId is saved twice (browser retries, double-tap on the share sheet), we catch the Prisma P2002 unique-violation and return 200 instead of 500.

iOS: probabilistic via IP, with explicit guards. iOS has no Play Install Referrer equivalent. Universal Links cover the already-installed case, but for the deferred path (link → store → install → open), we still need a side-channel. We chose IP — but as a named, time-boxed, ambiguity-guarded fallback, not a hidden heuristic.

The shape:

  • Web redirect saves req.ip alongside the deferred params at click time.
  • SDK on first launch sends its req.ip and the service identifier.
  • Backend looks up rows where clickIp == req.ip AND service == <service> AND createdAt > now - 60min.
  • If exactly one candidate, return it. If two or more, return nothing — public IPs (university Wi-Fi, corporate NAT) would otherwise misattribute.
  • Whatever we return is atomically claimed with updateMany({ where: { id, claimed: false } }). Zero rows updated → 404. Prevents the same click from being attributed to two installs.

It's honest about what it is: a short-window heuristic with a hard ambiguity floor. The 60-minute window and the "≥ 2 candidates → none" rule are tunable, and matchType is tagged on every response so we can see at a glance what fraction of installs were deterministic vs. probabilistic.

Phase 5: the deletion. Once both paths were in production, the old server-side generateFingerprint(IP+UA+lang+ts) function had no callers. We removed it — twelve lines and a crypto import gone. The client-side fingerprint still exists as a last fallback for Android installs that arrive with no referrer at all (some sideloaded paths), but the server no longer computes its own.

Before and after

Pure code-change counts are unflattering for an architecture post — we ended up with more lines than we started with, not fewer, because we replaced one heuristic with two narrower mechanisms plus their guards. But the things we actually care about did move:

  • Our deferred-params service went from 29 contract tests to 43 (all passing). The new tests cover idempotency, referrer round-trips, IP ambiguity, atomic claim, and the matchType labelling — i.e. the failure modes the old design had no opinion about.
  • The web redirect page is fetch keepalive + abort-backed. The "stuck on Opening…" reproduction we used to see no longer reproduces.
  • Server-side identifying data is strictly less: no more SHA256 over IP-UA-language. iOS does store clickIp for up to 60 minutes, but that data was already in our nginx logs at the same granularity.
  • Every install attribution now carries an explicit matchType (clickId / ip / fingerprint-legacy), which makes the next round of improvements measurable instead of vibes-based.

What we learned

  • Heuristics rot quietly. Fingerprinting "worked" until we tried to measure it. The fix wasn't a better hash — it was admitting we needed a deterministic path on at least one platform and treating the other as explicitly best-effort.
  • Side-effects should not block contracts. The redirect's contract is "send the user to the store". Logging is a side-effect. The minute we wrote it that way, the spinner bug stopped being a category of bugs.
  • Tag your matches. The single highest-leverage thing in the rewrite was returning matchType on every lookup. Now "is our attribution actually deterministic?" is a SQL query, not an argument.

What's next

Two threads. First, the 60-minute IP window is a guess — we'll start narrowing it based on the observed time-to-install distribution. Second, the client-side Android fingerprint still exists for the no-referrer fallback path; we want to either kill it once we have data on how often that path fires, or document it the same way we documented the IP fallback. Either is fine. Either is honest.


Try Eodin

Eodin builds small products that solve specific problems. See what we're working on.

Website

Share

Frequently Asked Questions

What was the main issue with using fingerprinting for deferred deep linking?

Fingerprinting relied on hashing IP, User-Agent, language, and timestamp, which was unreliable due to IP rotation, OS changes, and privacy features. This caused inconsistent match rates, especially on iOS, and led to lost attributions when the heuristic failed.

How does the new Android deferred deep linking method work without fingerprinting?

Android uses the Play Install Referrer API to pass a server-generated UUID called clickId through the Play Store URL. On first app launch, the SDK reads this clickId from the install referrer and fetches the deferred deep link parameters deterministically from the backend.

What approach does iOS use for deferred deep linking after removing fingerprinting?

iOS uses a probabilistic method based on matching the user's IP address within a 60-minute window, with strict ambiguity guards. If multiple clicks share the same IP in that timeframe, no attribution is made to avoid misattribution.

How was the web redirect spinner bug fixed in the new implementation?

The redirect no longer waits for analytics calls to complete before navigating to the store. Analytics requests are sent as fire-and-forget with fetch keepalive, ensuring the user is redirected immediately without getting stuck on a loading spinner.

What benefits did tagging each attribution with a matchType bring?

Tagging attributions with matchType (clickId, ip, or fingerprint-legacy) makes it possible to audit and measure the quality of attribution deterministically. This transparency helps identify how many installs are reliably matched versus heuristically guessed.

Continue reading