ATT prompt never appeared: fixing an App Tracking Transparency rejection
Apple reviewed the build on iOS 27 and wrote: the app uses the
AppTrackingTransparency framework, but the permission request could not be located. The code
called requestTrackingAuthorization at launch. The prompt still never showed. Both
statements were true at once.
This is the failure mode nobody documents, because it looks like a missing implementation when it is actually a timing bug. If your build links AppTrackingTransparency and Apple still says the prompt is absent, read the order of operations before you rewrite anything.
What Apple actually sent
“The app uses the AppTrackingTransparency framework, but we are unable to locate the App Tracking Transparency permission request when reviewed on iOS 27.0.”
Two paths follow: prove the prompt appears with a screen recording on a physical device, or remove the tracking declaration if the app does not track. Before choosing, check whether your call is even reaching the system.
Why a launch-time request returns nothing
On a cold start, a request fired from a module-scope effect can run while the app is still
becoming active. The system answers undetermined without presenting anything. The
call resolves, your await completes, and no UI ever appeared.
If your ads SDK initialises on the next line, the sequencing bug becomes a compliance problem: the app starts collecting before the user has answered. That is what a reviewer sees — a framework in the binary and a prompt that never appears.
// The shape of the bug: resolves, shows nothing, then ads start anyway
useEffect(() => {
requestTrackingPermissionsAsync(); // app may not be active yet
mobileAds().initialize(); // runs regardless of the result
}, []);
The fix is a sequence
Wait for the app to be active, read the current status, and only ask if it is still undetermined. Retry with backoff, because the first request can be dropped while the window is becoming key. Initialise ads last.
import { AppState, Platform } from "react-native";
import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync }
from "expo-tracking-transparency";
function waitForActive(timeoutMs = 4000): Promise<void> {
if (AppState.currentState === "active") return Promise.resolve();
return new Promise((resolve) => {
const sub = AppState.addEventListener("change", (s) => {
if (s === "active") { sub.remove(); resolve(); }
});
setTimeout(() => { sub.remove(); resolve(); }, timeoutMs);
});
}
async function ensureATT(): Promise<void> {
if (Platform.OS !== "ios") return;
for (let attempt = 0; attempt < 3; attempt++) {
const current = await getTrackingPermissionsAsync();
if (current.status !== "undetermined") return; // already answered
if (!current.canAskAgain) return;
const result = await requestTrackingPermissionsAsync();
if (result.status !== "undetermined") return; // prompt presented
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
}
}
Then the order in your root layout: waitForActive() → short settle →
ensureATT() → mobileAds().initialize(). Nothing collects before the
answer, and the prompt has an active window to attach to.
Check the binary, not the source
Before you blame the code, confirm what you actually shipped. The compiled JS bundle tells you whether the call was even in the archive:
APP=build/YourApp.xcarchive/Products/Applications/YourApp.app
grep -c "requestTrackingPermissionsAsync" "$APP/main.jsbundle"
/usr/libexec/PlistBuddy -c "Print :NSUserTrackingUsageDescription" "$APP/Info.plist"
A non-zero count plus a usage-description string means the implementation shipped and the problem is ordering. A zero means the build predates your fix — which happens more often than anyone admits, because archives are easy to reuse.
Replying to App Review
Name the mechanism, not the apology. The reply that works states the cause, the change, and what the recording shows:
- the request was made before the app was active, so the system returned without presenting
- the app now waits for the active state, verifies the status, and retries
- the ads SDK initialises only after the request completes
- attached: a recording on a physical device, fresh install, prompt before any ad request
Apple asks for the recording to be captured on a physical device, from a fresh install or after resetting tracking permissions, and attached in the Notes field of App Review Information. Simulators are not enough here — the reviewer knows the difference.
When removing tracking is the better call
If the app does not personalise ads, deleting the request and clearing the tracking declaration in App Store Connect is a legitimate path — and only the Account Holder or Admin can change privacy information. Be honest about what that costs: without ATT, ad requests are non-personalised, which lowers eCPM. If your revenue per thousand impressions is already in the low single digits, the personalisation you are protecting is worth very little, and the rejection loop costs more than the delta.
Related: the iOS 27 UIScene crash that hits builds compiled with the same SDK, ad frequency that survives App Review, and the pre-upload rejection checklist.
Get new posts
One email when a new post ships. No spam.