Why your App Tracking Transparency prompt doesn't show up (and how it got my app rejected)
App Review rejected my iOS app under Guideline 2.1. The note said reviewers were unable to locate the App Tracking Transparency permission request when they tested the build. The prompt worked on my iPhone. Every single launch. It just didn't work on theirs. The cause turned out to be two properties of the ATT API that are easy to miss individually and genuinely nasty in combination: together they produce a bug that is invisible on a fast device and completely reproducible on a slow one. Your test device is fast. The reviewer's device is not necessarily. This post is the root cause, the fix I shipped, and the list of other things that silently suppress the prompt. The two facts that explain everything 1. iOS only presents the ATT prompt while your app is active Apple's documentation for requestTrackingAuthorization(completionHandler:) states, for iOS 15 and later: "Calls to the API only prompt when the application state is UIApplicationStateActive." That's UIApplication.State.active - not merely "in the foreground," and not "the code is running." During launch there is a window where your JS/UI is already executing but the app is still inactive : splash screen dismissal, the first render, a modal transition animating in or out. Call the API in that window and iOS declines to present. 2. When iOS declines to present, you don't get an error You get notDetermined back (undetermined in expo-tracking-transparency ) - which is the exact same value you get when the user simply hasn't answered yet. There is no "I couldn't show it" signal. There is no thrown error. There is no presented: false flag. From the return value alone, "the user hasn't decided yet" and "iOS silently no-op'd your request" are indistinguishable. That's the trap. The API looks like it succeeded. The bug I shipped Reduced to its essentials: // Called during startup, while the splash screen was still going away. const { status } = await requestTrackingPermissionsAsync(); const granted = status === 'granted'; nonPersonalizedOnly = !granted; // and that's it - never asked again this process Two mistakes, stacked: - Requested too early. The call fired before the app reached active , so iOS sometimes skipped the prompt entirely. - Treated undetermined as a final answer. Anything that wasn'tgranted got folded into "not granted," cached for the process lifetime, and never retried. Individually, either one is survivable. Together they mean: if the first attempt slips, that install never sees the prompt again - not on that launch, and on subsequent launches the same race can repeat. On my phone, launch was fast enough that the app was usually active by the time the call landed. On the review device it wasn't. That timing difference is the entire distance between "ships" and "rejected." The tell: if status comes backundetermined after you explicitly requested authorization, that is not a user declining. That is iOS never asking. The fix, part 1: wait for active Don't request on a timer, and don't request "after 2 seconds" and hope. Observe the actual app state. import { AppState } from 'react-native'; const ACTIVE_WAIT_TIMEOUT_MS = 10_000; function waitUntilActive(): Promise { if (AppState.currentState === 'active') { return Promise.resolve(); } return new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; subscription.remove(); clearTimeout(timer); resolve(); }; const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') finish(); }); // Never leave this pending forever - ATT must not block the rest of startup. const timer = setTimeout(finish, ACTIVE_WAIT_TIMEOUT_MS); // Catch the case where we transitioned to active between the check above // and the listener being attached. if (AppState.currentState === 'active') finish(); }); } Two details worth stealing: - The re-check after subscribing. There is a real gap between reading AppState.currentState and the listener being registered. If the transition happens inside that gap, you wait for an event that already fired. This is the kind of race that shows up once a week in production and never on your desk. - The timeout. A permission helper that can hang forever will eventually hang forever, and it will take your ad SDK init (or worse, your splash screen) with it. Resolve on timeout and let the caller carry on. The fix, part 2: retry on undetermined instead of giving up Since "not presented" and "not answered" look identical, the only way to tell them apart is to try again and see if anything changes. import { getTrackingPermissionsAsync, isAvailable as isTrackingApiAvailable, requestTrackingPermissionsAsync, } from 'expo-tracking-transparency'; /** Spacing between presentation attempts. Length = max attempts. */ const ATT_ATTEMPT_DELAYS_MS = [600, 1_500, 3_000, 5_000, 8_000] as const; const delay = (ms: number) => new Promise ((r) => setTimeout(r, ms)); async function ensureTrackingConsent(): Promise { // Android / iOS | null = null; export function getTrackingConsent(): Promise { trackingConsent ??= ensureTrackingConsent(); return trackingConsent; } And don't await it before showing ads. Start ads in non-personalized mode, and let the consent result flip the flag when it arrives. A retry loop that gates your first ad request is a retry loop that costs you your first ad impression. The fix, part 3: give users a manual trigger There will still be environments where the automatic request slips. So I added a row in Settings - "Ad tracking settings" - that calls requestTrackingPermissionsAsync() directly on tap. This is worth doing for two reasons beyond the user-facing one: - It gives App Review a deterministic path to the prompt that doesn't depend on launch timing, and you can describe it in the review notes. - Once the user has answered, iOS won't show the dialog again - so this row should detect that state and deep-link to Settings.app instead of silently doing nothing.Linking.openSettings() handles that on iOS. const status = await getTrackingStatus(); if (status === 'undetermined') { await requestTrackingPermissionsAsync(); } else { await Linking.openSettings(); // already answered - only the OS can change it now } The same two rules in native Swift I ship the React Native version above, so treat this as the principle translated rather than production code I've run: import AppTrackingTransparency import UIKit final class TrackingRequester { private var observer: NSObjectProtocol? func requestWhenActive() { guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return } guard UIApplication.shared.applicationState == .active else { observer = NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } if let observer = self.observer { NotificationCenter.default.removeObserver(observer) self.observer = nil } self.requestWhenActive() } return } ATTrackingManager.requestTrackingAuthorization { status in // status == .notDetermined here means the prompt was never presented. // Schedule another attempt rather than recording this as a denial. } } } Same two rules: request only while .active , and never record .notDetermined as a decision. Other reasons the prompt won't appear Before you go rewrite your state handling, rule these out - several of them will make a correct implementation look broken: - The user already answered. iOS remembers the choice for the lifetime of the install. Per Expo's docs, it won't prompt again unless the app is deleted and reinstalled. This is the number one reason "my fix didn't work" - you already tapped a button on that device. - System-wide tracking requests are off. Settings โ Privacy & Security โ Tracking โ "Allow Apps to Request to Track" disabled means no app gets a prompt, ever. Your API call returns denied/undetermined with nothing shown. - Another permission dialog is pending. Apple documents that the prompt won't display while another permission request is awaiting the user. If you fire notifications + location + ATT at launch, they don't queue politely. Sequence them - request one, and only request the next from the previous one's completion handler. - You're calling from an app extension. Apple documents that calls through an app extension don't prompt. - NSUserTrackingUsageDescription is missing from your Info.plist. No string, no prompt. In Expo this goes inapp.json underios.infoPlist (or via the config plugin forexpo-tracking-transparency ). - iOS 13 or earlier / non-iOS. No ATT framework. Guard with an availability check so this path doesn't look like a failure. - Simulator. Behavior differs from device. Verify on hardware before concluding anything. Resetting so you can actually test the fix Because the answer is sticky per install, testing takes discipline: - Delete and reinstall the app. This is the reliable reset for the per-app status. - Toggling "Allow Apps to Request to Track" off and on in Settings also affects behavior, and is the fastest way to reproduce the "nothing shows up" state on purpose. - To reproduce the original bug rather than the fix, artificially delay reaching active - put the request behind a heavy synchronous startup path, or test on the oldest supported device you have. Fast hardware hides this defect. What I sent App Review Code changes alone weren't what got it through - evidence was. - A screen recording on a real device, from cold launch to the prompt appearing, unedited. - Review notes with explicit steps: launch the app, wait on the home screen, prompt appears; alternatively, Settings โ Ad tracking settings โ tap. - A one-line explanation of what changed since the previous submission. It passed on the third submission. One honest caveat: Apple documents the active -state requirement, but Apple does not document "retry on notDetermined " as the sanctioned remedy. That part is what fixed it in my case, on my code path. Tr
Comments
No comments yet. Start the discussion.