A magic-link sign-in works when the React Native app is running but is lost when the app was killed; what are the likely causes and fixes?
answer
- cold path is getInitialURL, not the event
- the link arrives before the app is ready
- splash and auth gate reset navigation
- hold a pending link until ready
- one-time token read twice
basics
~20 sThe cold path fails: getInitialURL() is never called, gets no launch data, or its URL is overwritten while splash and auth checks reset navigation. Read it once at the root, keep it pending, and route after startup.
solid answer
~50 sWarm links use the `'url'` event and cold links use `Linking.getInitialURL()`, so the fault is on the cold path. Check, in order: whether anything calls `getInitialURL()` at all (an app that only subscribes to `'url'` loses every cold link); whether the launch data exists (iOS `launchOptions` passed to `startReactNative`, Android intent filters on the activity React Native uses rather than a splash activity); and whether the URL is read but **dropped**, typically because the app first shows a splash, restores the session and then resets navigation to Home or Sign-in, overwriting the link. The fix is to read the initial URL once at the root, keep it as a **pending link**, and act on it only after auth and navigation are ready. Also make the token exchange idempotent, because the same initial URL comes back after a reload.
code
tsx · 43 linesimport { useEffect, useRef } from 'react';
import { Linking } from 'react-native';
export function usePendingLink(ready: boolean, onLink: (url: string) => void) {
const pending = useRef<string | null>(null);
const handled = useRef(new Set<string>());
const readyRef = useRef(ready);
const onLinkRef = useRef(onLink);
useEffect(() => {
onLinkRef.current = onLink;
}, [onLink]);
const flush = () => {
const url = pending.current;
if (!readyRef.current || url === null) return;
pending.current = null;
if (handled.current.has(url)) return;
handled.current.add(url);
onLinkRef.current(url);
};
useEffect(() => {
let sawEvent = false;
const subscription = Linking.addEventListener('url', ({ url }) => {
sawEvent = true;
pending.current = url;
flush();
});
Linking.getInitialURL().then(initial => {
if (initial && !sawEvent) {
pending.current = initial;
flush();
}
});
return () => subscription.remove();
}, []);
useEffect(() => {
readyRef.current = ready;
flush();
}, [ready]);
}go deeper
Recall that a killed app gets its link from getInitialURL, not from the url event, so a handler with only the listener misses it.
Explain how launch data reaches getInitialURL on each platform and why startup navigation resets can overwrite a link that was read correctly.
Walk the diagnosis from missing call to missing launch data to overwritten state to duplicate token use, and fix it with a pending link consumed after readiness.
Make link intake a single owned startup concern, with auth gating and idempotent token exchange designed in, rather than per-feature listeners.
## Frame the symptom A magic-link sign-in means an email containing a link such as `myapp://auth/magic?token=...` or a verified `https` equivalent. Tapping it should sign the user in. The report says: works when the app is open, fails when it was swiped away. That split points straight at the **cold-start path**, because the two states use different APIs: - running app: the `Linking` `'url'` event; - killed app: `Linking.getInitialURL()`, which resolves to the launch URL or `null`. ## Cause 1: nothing reads the initial URL The most common bug is a handler that only calls `Linking.addEventListener('url', ...)`. A cold start never emits that event. Add a single `getInitialURL()` call at the app root. If navigation is handled by React Navigation's `linking` option or Expo Router, they already call it; check that the magic-link route is actually part of their config rather than handled by a separate listener. ## Cause 2: the launch data never reaches React Native Log `await Linking.getInitialURL()` on a cold start. If it is `null` even though the app was opened by the link: - **iOS**: the app delegate must pass `launchOptions` into `startReactNative(withModuleName:in:launchOptions:)`; `RCTLinkingManager` reads the launch URL, or the Universal Link's user activity, from them. - **Android**: `getInitialURL()` reads the intent of the activity React Native is attached to. If a splash activity receives the link and starts `MainActivity` with a new intent, the URL is gone. - **A timeout**: React Navigation's default `getInitialURL` races the real call against a 150 ms timer, so a very slow cold start can resolve without a URL; a custom `getInitialURL` in the linking config can await it without the race. ## Cause 3: the URL is read, then overwritten This is the subtle one. A typical cold start runs: 1. show a splash while fonts, session and remote config load; 2. read the stored session; 3. `reset` navigation to Home or Sign-in. If the deep link was handled during step 1, step 3 overwrites it. In a warm start the app is already past step 3, which is why the same link works there. Symptoms include landing on Sign-in instead of completing the magic link, or a brief flash of the right screen. The fix is a **pending link**: - read the initial URL once, early, and store it in memory (a ref or a small store), not in navigation state; - when the app becomes ready (session restored, navigator mounted), consume the pending link: exchange the token, then navigate; - clear it once handled so it is not applied again. ## Cause 4: the one-time token is spent twice `getInitialURL()` is not cleared once read: a development reload or a re-mount of the root reads the same launch URL again. If the handler exchanges the token every time, the second exchange fails and the user sees an "expired link" error even though sign-in succeeded. Remember which URL was consumed, or, in an Expo app, call `Linking.clearInitialURL()` from `expo-linking` after handling it. ## Cause 5: the app was only backgrounded, and the listener was not mounted Occasionally the report is really a warm-start problem: the `'url'` listener lives in a screen that is not mounted, such as a sign-in screen hidden behind a splash, and the event is not replayed. Registering the listener at the root fixes it. ## Where the fix lives If navigation uses React Navigation's `linking` option or Expo Router, the library already reads the initial URL; the pending-link logic then belongs in the **auth gate**: do not reset navigation over a screen the link opened, or remember the link and re-apply it after sign-in. If the app handles links itself, a small hook at the root owns the whole flow, as in the example, and every feature receives links from it rather than subscribing on its own. ## A diagnostic routine | Check | Tells you | |---|---| | Log `getInitialURL()` result on cold start | whether launch data reaches JS | | Log the `'url'` event at the root | whether warm links arrive | | Log when navigation reset runs | whether the link is overwritten | | Log token exchange calls with a request id | whether the token is used twice | Test on a release build as well as in development: cold-start timing differs, and development reloads create the duplicate-token symptom that release builds do not show the same way.
- Why should the pending link live outside navigation state?During a cold start the navigator may not be mounted yet, and the app's own startup logic usually resets navigation after restoring the session. Anything written into navigation state before that reset is lost. A ref or small store survives the reset, and the app consumes it once it knows whether the user is signed in.
- How do you reproduce a cold-start link reliably while testing?Force-stop the app, then open the link from outside it: on Android with `adb shell am start -a android.intent.action.VIEW -d "myapp://auth/magic?token=test"`, on an iOS simulator with `xcrun simctl openurl booted "myapp://auth/magic?token=test"`. Test a release build too, since development builds start differently.
saying these in an interview costs you the question
- If warm links work, cold links must use the same code path
- A cold start fires the 'url' event once JavaScript loads
- Navigating inside the getInitialURL callback is always safe
- getInitialURL returns null after the first read
- The fix is to add a longer setTimeout before navigating