RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-05-07Intermediate

Rork iOS App: Why Your App Tracking Transparency Prompt Never Shows Up — and How to Fix It

Three real causes the ATT (App Tracking Transparency) dialog never appears in Rork-generated iOS apps — Info.plist, call timing, and AdMob init order — with working code and on-device verification steps.

Rork515iOS109ATT9App Tracking Transparency3AdMob70IDFA2troubleshooting65

I have been running ad-supported indie iOS apps since 2014, mostly through AdMob. So when I built a new ad-monetized app with Rork and ran it on a real device, I expected the App Tracking Transparency prompt to pop up immediately. It never did. Not on first launch, not on any subsequent launch, not even after a full reinstall.

The console showed no errors. The build succeeded. The AdMob banner even rendered fine. But the IDFA stayed blank, my Firebase Analytics revenue dashboard was empty, and that familiar "Allow [App Name] to track your activity..." dialog was nowhere to be seen.

This is a surprisingly common failure mode for Rork-generated iOS apps. The AppTrackingTransparency framework is one of those pieces that fails silently when something is off — Apple deliberately designed it not to crash or warn, because the tracking flow is supposed to be invisible to users who say "no." That same design choice makes it brutal to debug. Below are the three causes I have actually run into, in the order I would check them.

Cause 1: Missing or empty NSUserTrackingUsageDescription in Info.plist

This is the most frequent culprit. Rork does not always add the ATT description to app.json automatically, so when EAS Build generates Info.plist, the NSUserTrackingUsageDescription key is simply absent.

Since iOS 14.5, calling requestTrackingAuthorization() on an app without this key does not show the dialog and silently returns .denied. No error is thrown, which is why so many developers assume the call "worked" and look elsewhere. There is no NSAssert, no exception, no warning in Xcode's console — just a quietly returned denial.

// app.json — add to ios.infoPlist
{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSUserTrackingUsageDescription": "We use this identifier to show ads that match your interests."
      }
    }
  }
}

Be specific in the message. Apple sometimes rejects vague phrases like "for advertising"; phrases such as "show ads that match your interests" or "deliver more relevant content" pass review more reliably. The reviewer reads this string verbatim during App Review, so write it as if you were explaining it to a non-technical user.

After editing, run eas build or expo prebuild --clean. I lost two hours skipping the --clean flag once. If a generated ios/ directory already exists, Expo prefers it over app.json, so your changes are silently ignored. The same trap catches people when they switch from a managed workflow to a bare workflow halfway through a project — the bare ios/ folder takes precedence forever after.

To verify the change actually landed, open ios/[YourAppName]/Info.plist in Xcode after prebuild and search for NSUserTrackingUsageDescription. If it isn't there, the change didn't propagate.

Cause 2: Calling requestTrackingAuthorization() too early

The ATT dialog only shows when the app is fully foregrounded and active. If you call it inside useEffect on app start, the call often fires before the app reaches the active state, the dialog never appears, and the API simply returns .notDetermined.

This is particularly nasty because it works fine in the simulator and fails silently on real devices. The simulator transitions to the active state much faster than a physical device, especially on older hardware, so a request that races the lifecycle in the simulator wins and shows the dialog. On a real device with a slower cold start, that same race loses, and the developer never sees the failure during local testing. I only discovered this after a TestFlight tester told me the ad opt-in dialog was missing.

// ✅ The reliable pattern
import { useEffect, useRef } from "react";
import { AppState, Platform } from "react-native";
import {
  requestTrackingPermissionsAsync,
  getTrackingPermissionsAsync,
} from "expo-tracking-transparency";
 
export default function App() {
  const requestedRef = useRef(false);
 
  useEffect(() => {
    if (Platform.OS !== "ios") return;
 
    const subscription = AppState.addEventListener("change", async (state) => {
      if (state !== "active" || requestedRef.current) return;
      requestedRef.current = true;
 
      const current = await getTrackingPermissionsAsync();
      if (current.status === "undetermined") {
        const result = await requestTrackingPermissionsAsync();
        console.log("ATT result:", result.status);
        // expected output: "granted" or "denied"
      }
    });
 
    return () => subscription.remove();
  }, []);
 
  return /* ... */;
}

Three things matter here. First, wait for AppState === "active" so the dialog has a chance to render. Second, gate the request behind a requestedRef so accidental remounts (hot reload, navigation pushes that re-mount the root) don't keep firing the request. Third, never re-request when the user has already decided — checking getTrackingPermissionsAsync() first avoids no-op API calls that pollute the logs.

If you ask Rork to "implement ATT," it sometimes generates a version that calls the API directly inside useEffect — replace it with the pattern above and the dialog becomes reliable across devices.

A subtle variant of this bug occurs when developers wrap the request in a screen component instead of the app root. If the user happens to background the app before reaching that screen on first launch, the request can fire while the app is inactive on resume. Putting the listener at the root component avoids that whole class of problems.

Cause 3: AdMob SDK initialized before the ATT result is decided

This is the trap that fooled me longest. Google Mobile Ads tries to read the IDFA during SDK initialization. If the SDK initializes before ATT is resolved, even a later "Allow" gives you non-personalized ads for that session, because the SDK has already cached the absence of an IDFA. Every subsequent ad request in that session uses the non-personalized signal, so your eCPM stays low even though the user agreed to tracking.

The correct order is:

  1. Launch the app
  2. Show the ATT dialog and wait for the user's choice
  3. Initialize AdMob only after the result is known
import mobileAds from "react-native-google-mobile-ads";
import { requestTrackingPermissionsAsync } from "expo-tracking-transparency";
 
async function initAdsWithConsent() {
  if (Platform.OS === "ios") {
    const { status } = await requestTrackingPermissionsAsync();
    console.log("ATT decided:", status);
  }
  // Initializing AFTER the ATT result lets personalized ads work
  await mobileAds().initialize();
  console.log("AdMob initialized");
}

When you ask Rork to wire up an ad SDK, it often places mobileAds().initialize() at the very top of App.tsx. That runs before ATT, so you'll need to reorder it manually. The same caveat applies to other ad networks — AppLovin, Unity Ads, IronSource — each of them caches the IDFA at init time, and each will give you non-personalized inventory if you initialize first.

For apps using consent management platforms like Google's UMP (User Messaging Platform) for GDPR, the correct order extends to: GDPR consent → ATT → SDK initialization. Reversing any of those three creates the same cached-state bug.

A testing trap: once decided, the dialog never shows again

Once the user taps "Allow" or "Don't Allow," the dialog is permanently dismissed for that app. Forgetting this during development leads to the maddening "I fixed it, but the dialog still doesn't appear — my fix is broken" loop.

To reset on a real device:

  • Same app, retest: Settings → Privacy & Security → Tracking → toggle the app off and back on. This restores the .notDetermined state and lets the next launch re-prompt.
  • Full reset: Uninstall and reinstall the app. Note that IDFA is not regenerated by reinstall alone — that requires a device-wide reset of advertising identifiers in Settings → Privacy & Security → Apple Advertising → Reset Advertising Identifier.
  • Simulator: Device → Erase All Content and Settings. The simulator's ATT decision is tied to the simulator instance, so erasing it gives a clean slate.

I burned a full day on this before realizing the issue. The fastest sanity check is to open the iOS Settings app, tap your app, and confirm the tracking toggle's actual state. If it's off and you can't toggle it on, the user has denied with canAskAgain: false — the only path forward is to ask them to flip it manually in Settings.

A debug snippet I keep handy

When troubleshooting, I always log the current ATT state on every launch.

import { getTrackingPermissionsAsync } from "expo-tracking-transparency";
 
useEffect(() => {
  (async () => {
    const status = await getTrackingPermissionsAsync();
    console.log("=== ATT status ===");
    console.log("status:", status.status); // "granted" | "denied" | "undetermined"
    console.log("canAskAgain:", status.canAskAgain);
    console.log("expires:", status.expires);
    // example outputs:
    // status: "undetermined"   → not yet asked (still requestable)
    // status: "denied" canAskAgain: false → user denied; you cannot reprompt
  })();
}, []);

When canAskAgain: false comes back, no amount of code will resurface the dialog. The pragmatic move is to add a UI hint that points the user to the Settings app — a small banner saying "Personalized ads are disabled. Tap to enable in Settings" with a Linking.openSettings() action.

App Store review considerations

Your NSUserTrackingUsageDescription string is read aloud to App Review. Generic strings like "Required for advertising" or "App functionality" get rejected under Guideline 5.1.2. Strings that explain a concrete user benefit — "Show ads about products you've shown interest in" — typically pass without issue. If you've been rejected for ATT-related reasons, rewriting this string to be more specific is usually faster than appealing.

App Review also tests on real devices, so if your timing bug means the dialog never appears, the reviewer will see exactly what your TestFlight users saw — and reject under Guideline 5.1.1. Confirming the dialog appears on a physical device before submission is the single most reliable App Review check for monetized apps.

A practical takeaway

Don't trust Rork's generated ATT code blindly. Make a habit of checking three places yourself: app.json, the request timing, and the SDK init order. These days I spin up a tiny standalone sample with just those three pieces, verify the dialog appears on a physical device, and only then start adding business logic.

It's an unglamorous part of the iOS lifecycle, but getting it right has noticeably moved my eCPM by 1.5x to 2x in real apps — well worth the careful setup. The difference between a $0.50 eCPM and a $1.50 eCPM at 100,000 daily impressions is roughly $36,000 a year. That math makes ATT correctness one of the highest-leverage things an indie developer can fix.

If you're working through related iOS gotchas, I would also recommend Why Your Rork App's Push Notifications Don't Fire and Fixing Permission Failures in Rork iOS Apps — together they cover most of the iOS privacy minefield.

Thank you for reading. If this saves you the day I lost on it, that makes it worth writing.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-04-21
Lifting ATT Opt-in Rates in Rork Apps — Turning iOS Tracking Consent into Real Ad Revenue
Implementation patterns that raise App Tracking Transparency (ATT) opt-in rates in Rork-built iOS apps, wired to your AdMob revenue. Covers purpose string copy, prompt timing, a pre-permission screen, cohort-level measurement, UMP (GDPR) ordering, and a dynamic non-personalized ads fallback.
Dev Tools2026-04-30
Implementing App Tracking Transparency in Rork: How to Pass App Store Review
How to implement App Tracking Transparency (ATT) in Rork-built iOS apps so they pass App Store review on the first try, with the right Info.plist copy, prompt timing, and graceful fallback when users deny tracking.
Dev Tools2026-06-16
I Initialized Ads Before Restoring Purchases, and Paying Users Saw a Banner Flash — Cold-Start Ordering for Rork (Expo) Apps
Consent, ATT, ad SDK init, purchase restore, and remote config all try to run in the same few hundred milliseconds at launch. Get the order wrong and a paying user sees a banner flash, or measurement fires before consent in the EEA. Here is how I fold a Rork-generated Expo app's startup into a single orchestrator and kill the races by design.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →