Build1 publisher3 min readPublished
The ATT prompt only fires when the app is active, and iOS never tells you when it didn't
A developer's App Store rejection under Guideline 2.1 traced back to one line in Apple's docs: the tracking prompt is skipped unless the app state is active, and the return value looks identical either way.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- App Review rejected the author's iOS app under Guideline 2.1, with a note saying reviewers were unable to locate the App Tracking Transparency permission request when they tested the build.
- The prompt worked on the author's own iPhone on every single launch, but did not work on the reviewers' device.
- Apple's documentation for requestTrackingAuthorization(completionHandler:) states, for iOS 15 and later: "Calls to the API only prompt when the application state is UIApplicationStateActive."
- UIApplication.State.active is narrower than "in the foreground" or "the code is running"; during launch there is a window where JS/UI is already executing while the app is still inactive (splash screen dismissal, first render, a modal transition animating in or out), and a call made in that window causes iOS to decline to present.
- In that case the API returns notDetermined (undetermined in expo-tracking-transparency), which is the exact same value returned when the user has not answered yet.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
An iOS build was rejected under Guideline 2.1 because App Review said its testers could not locate the App Tracking Transparency permission request in the build they ran, according to a writeup published on dev.to [1]. The prompt fired on the developer's own iPhone on every launch, which is the part worth paying attention to: the defect was a timing race, and the reviewer's hardware lost it [2].
The mechanism is documented. Apple's page for `requestTrackingAuthorization(completionHandler:)` says, for iOS 15 and later, that calls to the API only prompt when the application state is `UIApplicationStateActive` [3]. That is a narrower condition than "the app is in the foreground" or "my code is running." During launch there is a window in which UI and JS are executing while the app is still inactive: the splash screen going away, the first render, a modal animating in or out [4]. A request landing in that window is declined, not deferred [4].
What comes back is `notDetermined`, or `undetermined` in `expo-tracking-transparency` [5]. That is the same value you get when the user simply has not answered yet [5]. There is no thrown error, no `presented: false`, no signal of any kind that separates "iOS refused to show it" from "the sheet is still pending a decision" [6]. The author's rule of thumb is that an `undetermined` status returned immediately after an explicit authorization request is not a user declining, it is iOS never asking [7].
The rejected build stacked two ordinary mistakes. It requested authorization during startup, while the splash screen was still dismissing [8]. And it folded every non-`granted` status into "not granted," cached that for the process lifetime, and never retried [9]. The author's assessment is that either mistake is survivable alone; combined, an install whose first attempt slips never sees the prompt on that launch, and the same race can recur on the next one [10]. The developer's device was fast enough to usually reach `active` before the call landed; the review device was not [11].
The fix is to wait on observed state rather than a delay. The author's helper resolves immediately if `AppState.currentState` is already `active`, otherwise subscribes to `AppState` change events and resolves on the first `active` [12]. Two details in that helper are the useful part. It re-reads `AppState.currentState` after subscribing, because the transition can happen in the gap between the initial read and the listener being attached, which leaves you waiting on an event that already fired [13]. And it carries a 10,000 ms timeout, roughly ten seconds, so a permission helper cannot hang and drag ad SDK initialisation or the splash screen down with it [14][15].
Because "not presented" and "not answered" are indistinguishable from the return value, the author's conclusion is that the only way to tell them apart is to attempt presentation again and see whether the status changes [16].
Worth checking in your own build: whether your ATT call is gated on real app state or on a `setTimeout`, whether a non-granted status is cached for the process, and whether your slowest available test device, not your newest, is the one you validate the prompt on [3][9].