Fixing the iOS App Tracking Transparency Prompt That Never Appears in a React Native App
A feature that depends on the device's advertising identifier, or on tracking across apps, quietly behaves as if the user denied permission — but the App Tracking Transparency dialog itself never actually showed up on screen for them to answer. Almost every case traces back to calling the request at the wrong moment in the app's lifecycle, not a missing permission.
The Problem
An app calls the App Tracking Transparency API — typically through a library like react-native-tracking-transparency or expo-tracking-transparency — expecting the system dialog asking "Allow [App] to track your activity..." to appear once, early in the app's life, so the advertising identifier becomes available for attribution or analytics. On some launches it works exactly as expected. On others, the call resolves immediately to denied or unavailable without the dialog ever rendering on screen, and the user never gets a chance to grant it — not because they declined, but because the dialog was never actually shown.
Why It Happens
iOS refuses to show the ATT prompt unless the app is in the foreground and fully active
Apple's framework for this explicitly requires the requesting app to be the active, foreground app with no system-level interruption in progress. If the request is made while the app is still finishing its launch sequence, while a splash screen or native launch screen is still technically on top, or immediately inside a cold-start effect that fires before the app has settled into an active state, the system can silently decline to present the dialog at all and immediately return a status as if the user had already decided.
Calling the request too early in a JavaScript lifecycle hook races against native app-state transitions
A React Native app's JavaScript bundle can finish executing its initial render and fire a mount-time useEffect before the native side has fully reported the app as active rather than inactive — particularly right after a cold launch. Requesting ATT permission inside that first effect, with no check against the actual native app state, means the request frequently fires during a brief window where iOS doesn't consider the app eligible to show a system dialog, and the race is inconsistent specifically because it depends on device speed and whatever else the OS is doing at that exact moment.
A request made while a native modal, action sheet, or the keyboard is still animating gets the same silent refusal
Beyond the cold-launch case, triggering the ATT request while any other native-level UI transition is mid-animation — a modal still sliding into place, a keyboard still animating open or closed — can put the app in a transient state iOS doesn't consider appropriate for presenting a second system-level dialog on top, producing the same silent "already decided" result even well after the initial launch.
The Fix
1. Only request tracking permission after confirming the app is in the "active" AppState, not on an unconditional mount effect
import { useEffect } from "react";
import { AppState } from "react-native";
import { requestTrackingPermissionsAsync } from "expo-tracking-transparency";
useEffect(() => {
const requestIfActive = () => {
if (AppState.currentState === "active") {
requestTrackingPermissionsAsync();
}
};
if (AppState.currentState === "active") {
requestIfActive();
} else {
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
requestIfActive();
subscription.remove();
}
});
return () => subscription.remove();
}
}, []);
Checking the actual native AppState before firing the request — rather than assuming a mounted component means the app is fully active — removes the race against the launch sequence, since the request only fires once iOS itself confirms the app is in the state it requires to present the dialog.
2. Add a deliberate short delay after the active state is confirmed, rather than requesting in the very same tick
if (state === "active") {
setTimeout(() => {
requestTrackingPermissionsAsync();
}, 500); // give the launch/transition animation time to fully settle
}
Even after AppState reports active, a launch screen's dismissal animation or a cold-start transition can still be visually finishing for a brief moment; a short deliberate delay after the state change — a few hundred milliseconds is typically enough — gives that transition time to fully complete before the request fires, rather than requesting in the exact same tick the state changed.
3. Never request from inside a component that can mount before the user has seen any of the app's own UI
// Request from a point after the user has seen a first meaningful screen,
// not from the very first component the app renders:
function HomeScreen() {
useEffect(() => {
// Safe: this screen only mounts after the launch sequence is visually done
requestTrackingIfActive();
}, []);
}
Placing the request in a screen the user only reaches after the app has visually finished launching — rather than in the root component or a splash-screen wrapper — sidesteps the entire category of timing race by construction, since that screen mounting at all is itself evidence the launch transition has already completed.
4. Verify the request actually fired by logging the returned status, rather than assuming a lack of errors means it worked
const { status } = await requestTrackingPermissionsAsync();
console.log("ATT request result:", status); // "granted" | "denied" | "undetermined" | "unavailable"
Logging the resolved status distinguishes a genuine user decision (granted or denied after the dialog was actually shown) from the silent-refusal case (unavailable, or an immediate denied with no dialog ever appearing), which is the concrete signal that the timing issue above is still happening rather than the user having made a real choice.
Why This Works
Every fix addresses the same underlying constraint from a different angle: iOS only presents the ATT dialog when it considers the app fully, stably active, and anything requesting it before that point gets treated as ineligible rather than queued or retried automatically. Gating the request on the actual native AppState removes the dependency on JavaScript mount timing lining up with native readiness by coincidence. A short deliberate delay absorbs the remaining visual-transition window that an active state report doesn't fully account for. Moving the request to a later screen sidesteps the race by never attempting it during the riskiest window at all. And logging the actual resolved status turns "it doesn't seem to work sometimes" into a concrete, verifiable distinction between a real decision and a silently skipped prompt.
Conclusion
An ATT prompt that never appears isn't usually a missing permission or a library bug — it's iOS declining to present a system dialog because the request landed during a brief window, typically right after a cold launch, where the app wasn't yet considered fully active and stable. Gate the request on the real native AppState rather than a JavaScript mount effect, add a short deliberate delay after that state is confirmed, consider moving the request to a screen the user reaches after the launch sequence has visually finished, and log the resolved status to confirm whether the dialog is actually appearing rather than being silently skipped.
