DEV Community

ninomaeDev
ninomaeDev

Posted on

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
Enter fullscreen mode Exit fullscreen mode

Two mistakes, stacked:

  1. Requested too early. The call fired before the app reached active, so iOS sometimes skipped the prompt entirely.
  2. Treated undetermined as a final answer. Anything that wasn't granted 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 back undetermined 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<void> {
  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();
  });
}
Enter fullscreen mode Exit fullscreen mode

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<void>((r) => setTimeout(r, ms));

async function ensureTrackingConsent(): Promise<boolean> {
  // Android / iOS < 14: the library reports the granted-equivalent. Nothing to ask.
  if (!isTrackingApiAvailable()) return true;

  // Already answered (this install, or a previous launch)? Then iOS will never
  // show the dialog again, and requesting is pointless.
  const { status: current } = await getTrackingPermissionsAsync();
  if (current !== 'undetermined') return current === 'granted';

  for (const settleMs of ATT_ATTEMPT_DELAYS_MS) {
    await waitUntilActive();
    await delay(settleMs);

    // We may have dropped back to inactive while waiting. Requesting now would
    // just burn an attempt with nothing shown.
    if (AppState.currentState !== 'active') continue;

    const { status } = await requestTrackingPermissionsAsync();
    if (status !== 'undetermined') {
      return status === 'granted'; // Prompt appeared and the user answered.
    }
    // Still undetermined → it was never presented. Back off and try again.
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

Why an increasing delay rather than a fixed one: the situations that suppress the prompt (transition animations, another permission dialog on screen, the system settling after launch) all resolve on their own, but on timescales you can't predict. Short first attempt so a healthy launch prompts quickly; longer later attempts so a slow device still gets there.

Don't hammer it. Requesting permissions in a tight loop is its own failure mode, and other permission dialogs (notifications, location) put your app into inactive while they're on screen — so a burst of retries during a notification prompt is five guaranteed misses.

Also worth noting: this whole helper should run once per process and hand the same promise to every caller. Two concurrent ATT requests do not queue up; the second one is simply lost.

let trackingConsent: Promise<boolean> | null = null;

export function getTrackingConsent(): Promise<boolean> {
  trackingConsent ??= ensureTrackingConsent();
  return trackingConsent;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

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.
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 in app.json under ios.infoPlist (or via the config plugin for expo-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. Treat it as a field report, not as a spec.

Bonus: the other rejection, if you ship subscriptions

Since it's the same trip through App Review and it's another pure "did you know" problem — my first rejection was Guideline 3.1.2: auto-renewable subscriptions offered without a functional link to the Terms of Use (EULA) in the app's metadata.

I had the links in the in-app paywall. What was missing was the store-side metadata.

The reliable fix: put the EULA link directly in your App Description, regardless of whether you use the standard EULA or a custom one. App Store Connect's App Information screen has a License Agreement section, but if you select the standard EULA there is no URL field there to fill in — so the description text is where a reviewer can actually click it.

Apple's subscriptions page states plainly that your app and your App Store metadata must include links to your Terms of Use and Privacy Policy, with no conditional attached. Standard EULA URL:

https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
Enter fullscreen mode Exit fullscreen mode

Metadata-only change, no rebuild required. Ten minutes once you know; two days if you don't.

Context: what this was for

The app is QuesToDo, an offline-first RPG-flavored todo app for iPhone — finish a task, earn EXP, level up a pixel-art character. Built solo over 23 days with an AI agent (Claude Code) doing implementation while I owned design decisions and on-device verification. Expo SDK 54 / React Native 0.81 / TypeScript strict, expo-sqlite with additive migrations v1 → v9, 366 tests across 25 suites, 14,451 lines of implementation code. All data stays on device; there is no backend.

No numbers to report on the other side of the launch — it shipped on 2026-08-07 and I have no downloads, users, or revenue worth writing about. This post is about the review process, not about traction.

If you want to look at it: QuesToDo on the App Store (free, with in-app purchases).

TL;DR

  1. iOS only presents the ATT prompt when your app is in the active state. Wait for it — observe AppState / didBecomeActiveNotification, don't guess with a timer.
  2. When iOS declines to present, the API returns notDetermined with no error. notDetermined after a request is not a denial. Retry it with backoff.
  3. Request once per process, don't stack it against other permission dialogs, and don't block your ad SDK on the result.
  4. Add a manual trigger in Settings and film it for App Review.

日本語まとめ (Japanese summary)

ATTダイアログが「審査環境でだけ」出ない問題の原因と対処

原因は2つの事実の組み合わせです。

  1. iOSのATTダイアログは、アプリが UIApplicationStateActive のときしか表示されません。 起動直後(スプラッシュ解除中・初回レンダー中・モーダルの遷移中)はまだ inactive のことがあり、そこで要求しても提示されません。
  2. 提示が見送られたとき、APIはエラーを返しません。 ダイアログを出さないまま notDetermined(Expoでは undetermined)を返します。これは「ユーザーがまだ答えていない」ときと同じ値なので、戻り値だけでは「出せなかった」と「まだ答えていない」を区別できません。

自分のコードは起動直後に要求し、返ってきた undetermined を「拒否された」と解釈してプロセス内で二度と要求しない設計でした。手元のiPhoneは起動が速くたまたま active に間に合っていた、それだけの差で審査では出ませんでした。

対策は3点セットです。

  • (1) AppState を購読して active になるまで待ってから要求する(購読直前に遷移した取りこぼしを拾う再チェックと、永久pendingを防ぐタイムアウトを入れる)
  • (2) undetermined は「拒否」ではなく「提示失敗」として、間隔を空けて再試行する(600ms / 1.5s / 3s / 5s / 8s)。他の権限ダイアログ表示中はアプリが inactive になるため、権限要求を連打しない
  • (3) 設定画面に手動要求の導線を置く。回答済みの端末ではiOSがダイアログを出さないので、その場合は Linking.openSettings() で設定アプリへ送る

ダイアログが出ない原因は他にもあります。回答済み(削除+再インストールでのみリセット)、「Appからのトラッキング要求を許可」がオフ他の権限ダイアログが処理待ちApp Extensionからの呼び出しNSUserTrackingUsageDescription の未設定。修正前に必ず切り分けてください。

審査には実機の画面収録(起動→ダイアログ表示)と、操作手順を書いたレビューノートを添付しました。3回目で承認されました。なお「notDetermined を再試行すべき」とAppleが明文化しているわけではなく、これは自分の環境でこう直したら通った、という体験ベースの話です。

おまけとして、サブスクを提供する場合はアプリ説明文(App Description)にEULAのリンクを直接書く必要があります(Guideline 3.1.2)。アプリ内のペイウォールにリンクがあるだけでは足りず、ストア側のメタデータにも要ります。標準EULAを選ぶとApp Store ConnectにURL入力欄がないため、説明文に書くのが確実です。この修正はメタデータのみ・再ビルド不要でした。

ATTに加えてSQLiteの追記型マイグレーションと時刻の引数注入まで含めた日本語の記事はQiitaにあります: https://qiita.com/ninomaeDev/items/d56129c46c227d09f6cc

Top comments (0)