Deferred Deep Linking in React Native: One API for iOS and Android
Set up deferred deep linking in React Native for iOS and Android. Use one callback, gate navigation, check match confidence, and test real first installs.
TL;DR: Deferred deep linking in React Native lets a user tap a link, install your app from the App Store or Play Store, and still land on the right screen on first launch. React Native's own
LinkingAPI cannot do this, becausegetInitialURLonly reports a URL the OS handed your app and a fresh install has no URL to hand over. The work happens in two halves instead: the click is recorded at the edge with the signals a browser exposes, and on first launch a native module collects the device's own signals so the server can match the install back to that click. The two platforms get there differently. Android reads the Play Install Referrer for a deterministic match, while a first install on iOS is matched probabilistically with a confidence score. Both surface through one JavaScript callback, wheredeepLink.isDeferredistrueandmatchGuaranteedtells you whether the match was deterministic or probabilistic. With@warplink/react-native1.1.1 that is oneWarpLink.configure({ apiKey, onLink })call and no per-platform branch. The rest of this guide is the mechanism, the TypeScript, and the testing.
The Minimal End-to-End Example
This is the whole integration for the first open after an install, written against @warplink/react-native 1.1.1. Each part is explained further down.
npm install @warplink/react-native@1.1.1
cd ios && pod install
// src/warplink.ts, imported once from index.js before the app renders
import { WarpLink } from '@warplink/react-native';
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000', // an SDK key, from Keys > SDK keys
debugLogging: __DEV__,
onLink: ({ deepLink, error }) => {
if (error) {
console.warn('WarpLink error:', error.code, error.message);
return;
}
if (deepLink?.isDeferred) {
// First open after install. matchGuaranteed is false for an iOS probabilistic match.
console.log(deepLink.linkId, deepLink.matchType, deepLink.matchConfidence);
// Queue the link and route it once the navigator is ready (see the navigation section).
}
},
}).catch((configureError) => {
console.warn('WarpLink configure failed:', configureError);
});
configure() starts the deferred check on its own. The native module records the install, the server matches it to the click, and a match arrives in onLink with isDeferred set to true. No other call is required for the first open. The sections below add the iOS Podfile line, the navigation routing, and the testing steps that keep the result honest.
Why React Native Has No Deferred Deep Linking API
React Native gives you exactly one door for incoming links, and it is Linking. You call Linking.getInitialURL() for the cold start case and Linking.addEventListener('url', handler) for the warm start case, and between them you cover every link that arrives while your app exists on the device.
That last clause is the whole problem. Both APIs are reporters: they tell you about a URL the operating system already routed into your process, and the OS only routes a URL into a process that is installed and registered for that domain. When the user taps your link without the app, there is no process, no registration, and no URL event. iOS sends them to the App Store, Android sends them to the Play Store, and the link's destination stops there. The user installs, opens the app, and getInitialURL() resolves to null, not because anything failed but because nothing was delivered.
Deferred deep linking is the workaround for a delivery channel that does not exist. Instead of transporting the link through the install, it records the click on one side, records the install on the other, and matches the two afterwards. This is also why no amount of React Navigation configuration fixes it: a linking config is a URL-to-screen mapping, and there is no URL. The deferred result arrives as a plain object from a native module, so it is your code, not the navigator, that routes it.
Two Platform Mechanisms Under One JavaScript API
Cross-platform deferred linking is interesting in React Native because iOS and Android solve the same problem with different machinery, and you want one code path on top of both.
Android: the Play Install Referrer
Android has a real answer. A redirect to the Play Store can attach a referrer string that the Play Store carries through the install and hands to the app afterwards. WarpLink sets that referrer to utm_source=warplink&utm_content={link_id}, so on first launch the native Android SDK reads it, sees the link id in plain text, and reports a deterministic match with confidence 1.0.
No inference and no window: the referrer names the click. This is why a test that works on Android tells you very little about how the same flow behaves on iPhone.
The referrer is not always there. It is missing when the app was sideloaded through adb install or a direct APK, when the device has no Google Play Services (Huawei devices running HMS, for example), and when the referrer data has expired. In each case the Android SDK falls back to the same probabilistic match iOS SDK uses, with no code change on your side.
iOS: a probabilistic match, with the IDFV for repeat checks
iOS has no referrer at all. The App Store passes nothing through the install, so there is no exact first-install signal. The SDK still sends the Identifier for Vendor whenever iOS provides one: it is stable across all apps from the same vendor on a device and exempt from App Tracking Transparency. When iOS returns no value, for example after a restart before the first unlock, the field is left out and the check runs on the match key alone. The IDFV answers the same install asking again with the result that install already got, its match type and its confidence unchanged.
A reinstall is deliberately not answered from the IDFV branch: it falls through to the probabilistic tier and is recorded there as a new install, which is what you want for install counting.
That tier compares signals captured when the link was tapped in the browser against signals collected when the app first launched: the IP address, the normalized primary language, and the timezone. The SDK sends the language and the timezone. It never sends the IP, because an app cannot see its own public address, so the server derives it from the request on both the click and the install side. Computing both halves in the same place is the only way the two hashes can agree.
The single JavaScript surface
Neither mechanism leaks into your code. Both platforms return the same WarpLinkDeepLink object through the same callback, and the only thing that differs by platform is the confidence in it:
interface WarpLinkDeepLink {
linkId: string;
destination: string;
deepLinkUrl: string | null;
customParams: Record<string, unknown>;
isDeferred: boolean;
matchType: 'deterministic' | 'probabilistic' | null;
matchConfidence: number | null;
matchGuaranteed: boolean;
}
isDeferred separates an install match from an ordinary tap. matchGuaranteed is what you branch on when the routing decision matters. Everything else is payload.
iOS and Android side by side
| Install path | How the match is made | matchType | matchConfidence | matchGuaranteed |
|---|---|---|---|---|
| Android, installed from the Play Store | The Play Install Referrer names the click | deterministic | 1.0 | true |
| Android, sideloaded or no Google Play Services | Probabilistic match: IP address, language, timezone | probabilistic | Up to 0.85 inside the first hour | false |
| iOS, first install from the App Store | Probabilistic match: IP address, language, timezone | probabilistic | Up to 0.85 inside the first hour | false |
| Either platform, click older than the match window | No match | null result | n/a | n/a |
The Confidence Model and What to Trust
A deterministic match, which means the Play Install Referrer on Android, returns matchConfidence of 1.0, matchType of deterministic, and matchGuaranteed set to true. A probabilistic match returns a score that decays with the gap between click and install, and matchGuaranteed stays false.
The probabilistic ceilings depend on the match key variant. Current SDKs send the IANA zone name, for example America/Toronto, which is the enriched_tz variant. Older SDKs send the minute offset instead, the enriched variant. When neither is usable, basic keys on IP and language alone.
| Time since click | enriched_tz | enriched | basic |
|---|---|---|---|
| < 1 hour | 0.85 | 0.80 | 0.70 |
| < 3 hours | 0.65 | 0.60 | 0.50 |
| < 6 hours | 0.50 | 0.45 | 0.35 |
| < 24 hours | 0.30 | 0.25 | 0.20 |
The zone name earns the higher ceiling because it carries far more entropy than the offset, roughly 340 zones against 38 offsets, and does not shift at a daylight saving boundary.
Two multipliers then adjust whichever ceiling applied. More than one claimable click sharing the match key applies x0.6. The click's IP address applies x0.6 for carrier grade NAT or a private address, x0.9 for a household IPv4 address, and x1.0 for IPv6.
The match window governs this tier only. It is set per link on the server, in the dashboard, not in the SDK. The default is 6 hours, set per link, up to 24 hours, so the bottom row of that table only applies to links configured past the default. The deterministic branches are unaffected by it.
The window is short deliberately. The match key describes a network, not a device, so every phone behind one shared address that shares a language and timezone lands in the same bucket, and each extra hour lets another stranger join it while adding almost no real matches. Inside the first hour, probabilistic matching is strong.
The practical rule that falls out of this: matchGuaranteed is not a credential, so use it, like a confidence threshold, to pick a destination. A probabilistic match can name the wrong person, so it should only ever route to a low-risk destination with a safe fallback, never sign someone in, restore a session, show personal data, or resume a purchase. Confidence is the second routing decision, and it only applies once matchGuaranteed is false: route to specific public content above 0.5, soften below it. Authenticate the user and check authorization separately before anything sensitive, whether or not the match was guaranteed.
Wiring It Up: One Call, Both Platforms
The package is @warplink/react-native 1.1.1, MIT licensed with no third party runtime dependencies. It needs React Native 0.75 or newer, React 18 or newer, iOS 15 or newer, and Android API 26 or newer.
npm install @warplink/react-native@1.1.1
cd ios && pod install
The iOS side comes through Swift Package Manager and is pulled in by the podspec, which requires dynamic frameworks. Add this inside your app's target block in ios/Podfile first:
use_frameworks! :linkage => :dynamic
Android auto links through the React Native CLI with no extra step.
Then call configure() once at startup, outside any component, so it runs before the first render rather than inside a useEffect.
// src/warplink.ts
import { WarpLink, type WarpLinkDeepLink } from '@warplink/react-native';
function handleLink(deepLink: WarpLinkDeepLink): void {
if (deepLink.isDeferred) {
// Matched from an install, not from a tap. Route by confidence.
routeDeferred(deepLink);
return;
}
routeTap(deepLink.deepLinkUrl ?? deepLink.destination);
}
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
onLink: ({ deepLink, error }) => {
if (error) {
console.warn('WarpLink error:', error.code, error.message);
return;
}
if (deepLink) {
handleLink(deepLink);
}
},
}).catch((configureError) => {
console.warn('WarpLink configure failed:', configureError);
});
That single call wires three sources into one callback: cold start (launched by a link), warm start (foregrounded by a link), and the deferred check. deepLink.isDeferred tells the third apart from the first two.
Two behaviors of configure() are worth knowing before you debug anything. It does not throw on a malformed key: it validates the format synchronously, reports a bad one through onLink as an { error } event with code E_INVALID_API_KEY_FORMAT, logs a warning, and leaves the SDK unconfigured. And an exception thrown by your own onLink propagates out of the promise configure() returns rather than coming back to you as a fabricated SDK error. That is why the .catch() is on the example above: on the first launch after an install, a navigation call inside onLink is the most likely thing in your app to throw.
Either automatic piece can be disabled and driven yourself:
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
automaticDeepLinks: false, // wire onDeepLink / getInitialDeepLink yourself
automaticDeferredDeepLinks: false, // call checkDeferredDeepLink yourself
});
Note the asymmetry. Omitting onLink does not switch the deferred check off, because that request is what attributes the install. Only automaticDeferredDeepLinks: false stops it.
Native host setup, and what deferred matching does not need
Two native hooks are required for tapped links, and neither is required for deferred matching. Separate them so you debug the right layer.
On iOS, your AppDelegate must forward incoming URLs to the SDK, because iOS does not hand Universal Links to native modules on its own:
// ios/YourApp/AppDelegate.swift
import React
import warplink_react_native
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
WarpLinkModule.handleIncomingURL(url)
}
return RCTLinkingManager.application(
application, continue: userActivity, restorationHandler: restorationHandler)
}
handleIncomingURL returns nothing and does no domain filtering, so never let it decide the delegate's return value. Keep returning whatever your existing linking code returned, or the deep links your app already handles stop arriving.
If your app declares a UIApplicationSceneManifest in Info.plist and has a SceneDelegate, the AppDelegate method above never runs: iOS routes delivery to the scene instead. Add the same WarpLinkModule.handleIncomingURL(url) call to SceneDelegate's own methods; see the iOS SceneDelegate hook in the React Native SDK guide.
On Android, set android:launchMode="singleTask" on your launch Activity so warm start intents reach the SDK through onNewIntent. Cold start is read from the launch intent automatically.
Neither touches the deferred path, which runs from configure() straight to the attribution endpoint. A missing AppDelegate call breaks tapped Universal Links while deferred matching keeps working, and a missing launchMode breaks warm start the same way. If your deferred link is fine but a tapped one is not, the native host setup is where to look.
Declare your app's subdomain, and your own domain if you serve links from one, so the SDK recognizes them on the first launch, before it has fetched your domain list:
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
linkDomains: ['yourapp.aplnk.to', 'links.yourapp.com'],
onLink: ({ deepLink }) => deepLink && handleLink(deepLink),
});
The Manual Check and the Splash Gate
The automatic dispatch is right for most apps, with one exception: when your first screen depends on the answer and you would rather hold a splash than render onboarding and then yank it away. Disable the automatic dispatch and await the check yourself. checkDeferredDeepLink() returns Promise<WarpLinkDeepLink | null>, resolving to null when there was no match.
// App.tsx
import { useEffect, useState } from 'react';
import { WarpLink, type WarpLinkDeepLink } from '@warplink/react-native';
import { RootNavigator } from './src/RootNavigator';
import { SplashScreen } from './src/SplashScreen';
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
automaticDeferredDeepLinks: false,
});
export default function App(): React.JSX.Element {
const [isReady, setIsReady] = useState(false);
const [deferredLink, setDeferredLink] = useState<WarpLinkDeepLink | null>(null);
useEffect(() => {
let cancelled = false;
WarpLink.checkDeferredDeepLink()
.then((link) => {
if (!cancelled && link?.isDeferred) {
setDeferredLink(link);
}
})
.catch((error: Error) => {
// Offline on first launch resolves as E_NETWORK_ERROR. The attempt is
// not consumed, so the SDK retries on the next launch.
console.warn('Deferred check failed:', error.message);
})
.finally(() => {
if (!cancelled) {
setIsReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
if (!isReady) {
return <SplashScreen />;
}
return <RootNavigator initialDeferredLink={deferredLink} />;
}
Two properties of the check make this safe. It runs once per install: the native SDK writes a completion marker only after the check has definitively completed, and every later call resolves from the cache with no network request. And an attempt that produced no usable answer, such as an offline first launch, is not recorded as complete, so it retries on the next launch instead of caching a permanent null.
That marker is install scoped on purpose. It is a backup excluded file in the app container on iOS and a file in noBackupFilesDir on Android, and no restore brings either back, so a reinstall attributes fresh. A separate device level marker outlives the uninstall (the Keychain on iOS, SharedPreferences on Android) and only sets is_reinstall on the request.
Routing With React Navigation 7
Here is the part that catches people. React Navigation 7 configures URL based deep links with the linking prop:
import { NavigationContainer } from '@react-navigation/native';
const linking = {
prefixes: ['https://yourapp.aplnk.to', 'myapp://'],
config: {
screens: {
Product: 'product/:id',
Home: '',
},
},
};
If your app still has links on aplnk.to, add 'https://aplnk.to' to prefixes as well.
That config will never route a deferred link. It maps URLs to screens, and a deferred match is not a URL: it is an object arriving from a native module after the app has already launched. Keep linking for tapped links, and route the deferred object yourself, through a navigation ref, because the callback can fire before the navigator has mounted. On the first launch after an install, it usually does.
// src/navigation.ts
import { createNavigationContainerRef } from '@react-navigation/native';
import type { WarpLinkDeepLink } from '@warplink/react-native';
export type RootStackParamList = {
Home: undefined;
Product: { id: string };
Welcome: { suggestion: string | null };
};
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
let pendingLink: WarpLinkDeepLink | null = null;
/** One query value off the link's own deep link URL, percent-decoded. */
function readParam(url: string | null, key: string): string | null {
if (url === null) return null;
const query = (url.split('#')[0] ?? '').split('?')[1] ?? '';
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
const rawValue = eq === -1 ? '' : pair.slice(eq + 1);
if (decodeURIComponent(rawKey) !== key) continue;
const value = decodeURIComponent(rawValue.replace(/\+/g, ' '));
return value === '' ? null : value;
}
return null;
}
export function routeDeferred(link: WarpLinkDeepLink): void {
if (!navigationRef.isReady()) {
// The navigator has not mounted yet. Hold the link and let onReady flush it.
pendingLink = link;
return;
}
const productId = readParam(link.deepLinkUrl, 'product_id');
// Deterministic only. Identity work needs a guarantee, not a high score.
if (link.matchGuaranteed && productId) {
navigationRef.navigate('Product', { id: productId });
return;
}
const confidence = link.matchConfidence ?? 0;
if (confidence > 0.5 && productId) {
navigationRef.navigate('Product', { id: productId });
} else if (confidence > 0.3) {
navigationRef.navigate('Welcome', { suggestion: link.destination });
} else {
navigationRef.navigate('Welcome', { suggestion: null });
}
}
export function flushPendingLink(): void {
if (pendingLink && navigationRef.isReady()) {
const link = pendingLink;
pendingLink = null;
routeDeferred(link);
}
}
Then attach the ref and flush from onReady, which React Navigation calls once the container has mounted and the ref is usable:
// src/RootNavigator.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { navigationRef, flushPendingLink, type RootStackParamList } from './navigation';
import { HomeScreen } from './HomeScreen';
import { ProductScreen } from './ProductScreen';
import { WelcomeScreen } from './WelcomeScreen';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator(): React.JSX.Element {
return (
<NavigationContainer ref={navigationRef} onReady={flushPendingLink}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Product" component={ProductScreen} />
<Stack.Screen name="Welcome" component={WelcomeScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
On React Navigation 7's static API the same two props exist on the component createStaticNavigation returns, so ref and onReady move there unchanged.
The typed RootStackParamList is worth the extra lines. The product id comes off deepLinkUrl, the link's own per-platform deep link URL returned verbatim, so it carries whatever you wrote into that URL when you created the link. It is string | null and a query string is untyped text, so check for null before navigating with it.
With or Without Expo Development Builds
The short answer: deferred matching works in an Expo development build, an EAS build, or a bare React Native app, and it does not work in Expo Go. The check needs a native module, and Expo Go cannot load one. The full Expo walkthrough, including the prebuild gap and how to test a real install, is in Deferred Deep Linking in Expo: Setup, Testing, and Pitfalls. The rest of this section covers the parts that matter for the code above.
Expo works with a development build. Deferred matching needs a native module, and Expo Go cannot load native modules, so use a development build:
npx expo install @warplink/react-native
npx expo prebuild
npx expo run:ios
Use that development build, or an EAS build, for every test. Expo Go reports the module as unlinked, which looks like a broken SDK and is not one.
The associated domains entitlement and the Android intent filter both go in app.json and survive prebuild:
{
"expo": {
"ios": {
"associatedDomains": ["applinks:yourapp.aplnk.to"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "yourapp.aplnk.to" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Two supported ways to keep the AppDelegate calls through every prebuild, from the SDK's own integration guide:
- Commit the generated
ios/directory and add thehandleIncomingURLcalls to theAppDelegatethere. - Add the calls from your own Expo config plugin using
withAppDelegate, so they survive every prebuild.
The Android launch mode has the same shape of answer: set it through the expo-build-properties config plugin or under android in app.json.
Worth repeating, since it changes how much of this blocks you: the AppDelegate call is required for tapped Universal Links, not for the deferred check. An Expo app still attributes installs and still receives deferred matches while the AppDelegate call is being added.
For a custom domain, set the iOS WarpLinkDomains key through expo.ios.infoPlist in app.json and the Android app.warplink.DOMAINS entry from a config plugin, or skip both and pass linkDomains to configure().
Testing Deferred Deep Links on Each Platform
Deferred matching is the hardest thing in a linking stack to test, because every shortcut you would normally take invalidates the test. The rule underneath all of it: a deferred check fires once per install, on the genuine first launch.
Both platforms, first. Create a test link in the dashboard with a destination and, if you have one, an iOS and Android deep link URL. Turn on debugLogging: true in configure() so the native side traces what it did. Keep the gap between tapping the link and opening the installed app short, ideally inside an hour, both to stay inside the match window and to land in the top confidence band.
iOS. Use TestFlight. Re-running a build from Xcode is not a fresh install and does not reset the completion marker, so it returns the cached result and looks exactly like a broken match. Deleting the app and installing it again is enough for an ordinary retest. For the first install path rather than the reinstall path, erase the simulator (Device, then Erase All Content and Settings, or xcrun simctl erase) or use a device that has never run the app.
Android. Use an internal testing track install. That is the only route that produces a real Play Install Referrer, so it is the only way to test the deterministic path Android users actually get. A sideloaded APK has no referrer and falls through to the probabilistic match. That is still a useful test, since it exercises the path iOS uses, but do not read it as proof the referrer branch works.
First-Open Test, Step by Step
Run this once per platform. It keeps the test valid, because a deferred check only fires on a genuine first launch.
- Create the link. In the dashboard, create a link with a destination and, if the screen needs it, a deep link URL such as
myapp://product?product_id=42. - Build the right app. Use a development build, an EAS build, or a bare React Native build with
debugLogging: true. Never Expo Go. - Start from a clean install. Delete the app. On the iOS simulator, use Device, Erase All Content and Settings. Re-running from Xcode or reloading Metro does not reset the install marker.
- Tap the link on the test device, from Notes or Messages, in Safari or Chrome. Do not paste it into the address bar.
- Install the app and open it within the hour, on the same network you tapped from. Inside the first hour an iOS probabilistic match is capped at 0.85.
- Read the result. The
onLinkevent arrives withisDeferred: true. If it does not, run the checks below before you change any code.
import { WarpLink } from '@warplink/react-native';
export async function logAttributionState(): Promise<void> {
console.log('native SDK version:', await WarpLink.sdkVersion()); // "1.1.1"
console.log('configured:', await WarpLink.isConfigured());
console.log('check complete:', await WarpLink.isAttributionComplete());
console.log('attribution:', await WarpLink.getAttributionResult());
}
isAttributionComplete() returns false until the deferred check has definitively finished for this install. getAttributionResult() returns the cached result of that check without a new network call, and null means no match. Call logAttributionState() from a debug button, not at startup.
What a good result looks like. On Android through the Play track: matchType of deterministic, matchConfidence of 1.0, matchGuaranteed true. On iOS through TestFlight on a device that never had the app: matchType of probabilistic, matchGuaranteed false, and around 0.85 if you tested inside the hour. Get those two and the wiring is correct.
A deferred match chooses a screen. It does not identify a user, so keep sign-in on your own auth and never restore a session or show personal data from a deferred match alone.
Why Isn't It Firing?
The checks below cover the common causes when a deferred link does not arrive.
- The navigator was not ready. The deferred dispatch runs from
configure(), which you called at module scope, before any component mounted. Anavigate()inonLinkthrows, and because an exception from your callback propagates out of theconfigure()promise, it surfaces as an unhandled rejection instead of a navigation. Queue the link, flush it fromonReady, and keep the.catch(). - You used an API key instead of an SDK key. They share the
wl_live_prefix and both pass the format check, but an API key cannot record installs. Create the credential under Keys > SDK keys. - You tested by re-running from Xcode or reloading the bundle. Neither is a fresh install. The completion marker survives both, so the check returns the cached result with no network request.
- Use a development build. The SDK includes native modules, so it runs in an Expo development build.
- You expected
Linking.getInitialURL()to see it. It never will. A deferred match does not travel as a URL, so it appears in neither React Native'sLinkingAPI nor React Navigation'slinkingconfig. - You got the same match twice. That is the cache working. The manual
checkDeferredDeepLink()always returns the cached result, while the automatic dispatch throughonLinkdelivers a match at most once across launches. Route from one or the other.
If the link fires but the app opens a browser instead of your screen, that is Universal Link or App Link routing rather than deferred matching, and it is diagnosed against the association files. Our deep link checker reads both files for a domain.
Frequently Asked Questions
Does deferred deep linking work with Expo?
Yes, in a development build. Deferred matching needs a native module, so it cannot run in Expo Go. Install the package with npx expo install, run npx expo prebuild, and build with EAS or npx expo run:ios. Add the iOS AppDelegate call through your own withAppDelegate plugin or in a committed ios/ directory.
Why does nothing happen on the first launch after install?
The three usual causes are a navigator that was not ready when the callback fired, an API key used where an SDK key was required, and a store gap longer than the match window. Queue the link and flush it from the navigation container's onReady, check that the key came from Keys then SDK keys in the dashboard, and keep the click to install gap short while testing.
Why is Android deferred matching more accurate than iOS?
The Play Store passes the click referrer through the install, so Android gets a deterministic match with confidence 1.0 and matchGuaranteed true. A first install on iOS is matched by a probabilistic match that decays from 0.85 inside the first hour. The same JavaScript handles both, and the confidence in the result reflects the platform.
How do I test deferred deep links without publishing to the stores? On iOS, use TestFlight: an Xcode rerun is not a fresh install and returns the cached result. On Android, use an internal testing track install, because that is the only way to exercise the real Play Install Referrer. A sideloaded APK still tests the probabilistic fallback, which is the path iOS uses anyway.
Do I need to call checkDeferredDeepLink myself in React Native?
No. configure() fires the check automatically and delivers a match through onLink with isDeferred set to true. Call checkDeferredDeepLink() yourself only when you set automaticDeferredDeepLinks: false, or when you want to gate your first screen on the result behind a splash. Note that omitting onLink does not switch the check off, because that request is what attributes the install.
Does deferred deep linking need the IDFA or an App Tracking Transparency prompt? No. The iOS side uses the IDFV, which is vendor scoped and exempt from App Tracking Transparency, plus a match key the server computes from the request. The SDK never touches the IDFA or the Google Advertising ID, so it adds no permission dialog to your first launch on either platform.
Sources
- React Native, Linking:
getInitialURL()reports only the URL that launched the app, which is why a fresh install has nothing to report. - Android Developers, Play Install Referrer: the referrer the Play Store carries through an install.
- Apple, identifierForVendor: the vendor-scoped identifier the iOS side uses instead of the IDFA.
- Expo, Introduction to development builds: why a native module needs a development build and cannot run in Expo Go.
- React Navigation, NavigationContainer: the
onReadycallback used to flush a queued link.
Related Guides
- Each platform in depth: Deferred Deep Linking on iOS and Deferred Deep Linking on Android go further into the IDFV, Private Relay, and the Play Install Referrer than a cross platform post can.
- The setup underneath this one: React Native Deep Linking covers the entitlement, intent filter, and React Navigation wiring for tapped links.
- Start here for the concepts: The Complete Deep Linking Guide for Mobile Developers.
- When a tapped link misbehaves: Universal Links Not Opening? for iOS, and Android App Links autoVerify Failed for the Android association file.
- The short version of the mechanism: how deferred matching works, with iOS and Android side by side in one table.
- Using Expo: Deferred Deep Linking in Expo: Setup, Testing, and Pitfalls covers why a development build is required and how to test a real install.
- Reference: the React Native deferred deep links docs, the SDK setup guide, the attribution docs, and the deferred deep links concept page.
- Choosing an SDK: 5 Deferred Deep Linking SDKs Compared sets the vendors' first-open matching, platform coverage, and published pricing side by side.
How WarpLink Helps
Everything above is the mechanism, and the mechanism does not change depending on who runs it. What a service has to provide are the two halves you cannot host inside your app: the click recorder at the edge that captures signals before the store takes over, and the attribution endpoint that computes the match key from the request IP and walks the match cascade. WarpLink's React Native SDK is the bridge between those and your JavaScript, and it collapses to WarpLink.configure({ apiKey, onLink }), one callback, and an isDeferred check. It is MIT licensed with no third party runtime dependencies, so the bridge is a few files you can read line by line.
The part worth saying plainly is that deferred deep linking is install attribution. The same match that routes the user also tells you which link, which campaign, and which share drove the install, because linkId, deepLinkUrl, matchType, matchConfidence, and matchGuaranteed come back in the same payload. Route the user and attribute the install in one call. That is the bridge from the linking pillar to the attribution pillar, and from there to the real time analytics that show which taps became users, on iOS and Android, from one codebase.
Create a free WarpLink account and get deep linking, install attribution, and analytics in one SDK, with 10,000 clicks a month on the free tier and no time limit. The React Native SDK docs have the full setup.
Get your first deep link working today
Deep linking, install attribution, and real-time analytics in one SDK. Free for 10K clicks a month, no sales call.
Stuck on setup? Email support@warplink.app and we will help you get one real link working.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
Deferred Deep Linking in Flutter: One Callback for iOS and Android
Deferred deep linking Flutter guide: route the first open after install with one WarpLink 1.1.1 callback, plus first-open test steps and confidence scoring.
Deferred Deep Linking on Android: How to Implement It in Kotlin
Implement deferred deep linking on Android with Kotlin. Use the Play Install Referrer, handle missing referrers, route first opens, and test on real devices.
Deferred Deep Linking in Expo: Setup and Testing
Expo deferred deep linking needs a development build, not Expo Go. The app.json setup, the iOS delegate call, and how to test a real install.