I Started the Ad SDK Before Asking for ATT — the Init-Order Bug That Quietly Lowered First-Session eCPM
When I rolled AdMob mediation out to four iOS apps, only the very first session showed weaker ad revenue. The cause was the order between the ATT prompt and MobileAds initialization. Here is why the order matters, plus how I had Antigravity audit the init sequence across all four apps.
Right after rolling mediation out to four iOS apps, I noticed an odd step in the Firebase dashboard. Daily eCPM was steady, yet the first impression of each session had a noticeably lower fill rate. No crashes, ads were showing — but the first slot or two right after a fresh install were filled with clearly lower-priced demand.
It took a few days to trace. The short version: I was initializing the ad SDK before the ATT (App Tracking Transparency) prompt had resolved. The code compiled, no warnings — which is exactly why this class of bug is easy to miss.
This is a record of the ordering pitfall I actually hit across the wallpaper and healing apps I run as an indie developer, why it matters, and how I had Antigravity audit the initialization sequence alongside the fix.
The tell: revenue dips on the first session only
In a mediation setup, each ad network reads device signals to bid. On iOS the signal that matters most is the IDFA (the advertising identifier). On devices where ATT returns "allow," the IDFA is available; where it does not, you get a zeroed value.
What matters is when the IDFA becomes final. If an ad request fires before the ATT result is in, the IDFA is not yet available at that moment. The SDK treats the device as non-trackable and builds its bid accordingly. The first request leans on non-targeted inventory, and the price drops. That was the "first session only" weakness I was seeing.
From the second session onward the ATT outcome is already stored by the OS, so the IDFA returns immediately and the step disappears. That is why the daily average hid it — you only see this degradation once you split the segment into "first session" versus "later sessions."
Why the order matters this much
Here are the four players, lined up by their dependency direction.
Reads the consent result, then builds bids and delivery
After ATT
SKAdNetwork
Attribution path that does not depend on consent
Handled by the SDK
The dependency runs one way. ATTrackingManager produces a result → the IDFA is finalized → MobileAds bids against that state. Break the order and MobileAds starts running while "nothing has been decided yet."
The official docs say to request ATT "before you load ads," but the trap in practice is the difference between "right after you call the request" and "after consent has returned." requestTrackingAuthorization is asynchronous and returns its result in a callback, so the moment right after you call it, nothing is settled. Unless you start MobileAds inside the consent callback, you lose the race even though you thought you respected the order. My first version did exactly the wrong thing — it started MobileAds immediately after the call.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Pin down why fill rate and eCPM dip on the first session only, narrowed to the single issue of ATT / IDFA / MobileAds initialization order
✦Copy a complete, race-free Swift snippet for the correct ATTrackingManager and MobileAds.start ordering straight into your own app
✦Hand the order check across scattered init calls to an Antigravity agent while you keep the delivery-policy decisions in human hands
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first implementation looked perfectly reasonable.
// ❌ ATT is only "called"; MobileAds starts without waiting for the resultfunc application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool { if #available(iOS 14, *) { // The request is fired, but we never wait on the callback ATTrackingManager.requestTrackingAuthorization { _ in } } // The consent above hasn't returned yet, but the ad SDK runs here MobileAds.shared.start(completionHandler: nil) return true}
This works. Ads show. But because MobileAds.shared.start runs before requestTrackingAuthorization returns, the first bids are built without the IDFA. The user answers the prompt a few seconds later, but the SDK does not wait.
The correct order (After)
The core of the fix is moving the MobileAds start inside the ATT callback.
// ✅ Start MobileAds only after the consent result is infunc application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool { requestTrackingThenStartAds() return true}private func requestTrackingThenStartAds() { if #available(iOS 14, *) { ATTrackingManager.requestTrackingAuthorization { status in // Whether status is .authorized or .denied, the OS decision is // final once we're here. Either way it's safe to start MobileAds. DispatchQueue.main.async { MobileAds.shared.start(completionHandler: nil) } } } else { // No ATT below iOS 14, so start immediately MobileAds.shared.start(completionHandler: nil) }}
The key point is that starting inside the callback is correct even on .denied. The goal is not "always obtain an IDFA"; it is "run the SDK only once the OS decision is final." If consent is denied, the SDK builds on a non-tracking assumption from the start, so there is no step between the first and second sessions. The step is the lost revenue — denial itself is not the problem.
There is a second hazard: a UI race. If your splash or first-run onboarding tries to appear at the same time as the ATT dialog, the dialog can hide behind onboarding and your consent rate falls. I delayed the ATT request until the first meaningful screen had rendered and the user could understand the context. The ad start still hangs off that same callback, so the order is preserved.
Having Antigravity audit the init sequence
Visually chasing whether all four apps repeat the same trap is tedious. I handed the mechanical matching to an Antigravity agent. What I keep is the policy — "what counts as the correct order" — and what I hand the agent is only the reading work of "list every place that fails that order."
The rule I gave the agent, in plain language, was just this.
# Init-order audit rule (added to AGENTS.md)- In the AppDelegate / SceneDelegate launch path, confirm that the call to MobileAds.shared.start is inside the ATTrackingManager.requestTrackingAuthorization callback.- List any place that calls start outside the callback (immediately after, in the same scope) as an "order violation."- Do not flag the pre-iOS-14 fallback branch that calls start immediately.- Report only. Do not auto-edit code; return file and line number for each finding.
When running it non-interactively from the CLI, keeping the output machine-readable makes it easy to wire into CI.
# List order violations in the launch path (no edits; a human fixes them)agy run --headless \ --prompt "Follow the init-order audit rule in AGENTS.md and list violations as file:line" \ --paths "Sources/App/*Delegate.swift" \ --format json > att-order-report.json# Fail the pre-release gate if there is even one violationtest "$(jq '.violations | length' att-order-report.json)" -eq 0
I let the agent go only as far as sweeping out the scattered start calls from an ordering point of view. The delivery-policy calls — which screen presents ATT, what to give up on denial — I kept in my own hands to the end. Hand that over and the machine may quietly pick an order that "works but isn't what you meant." Agent finds, human draws the line: that division held up best when fixing four apps at once.
Verifying on a real device
The dashboard average hides the step, so I verified with on-device logs and a segment split.
The first check is reading the launch sequence log in time order. If the requestTrackingAuthorization callback comes before the MobileAds start completion handler, the order is respected.
ATTrackingManager.requestTrackingAuthorization { status in print("ATT settled: \(status.rawValue)") // ← this comes first DispatchQueue.main.async { MobileAds.shared.start { _ in print("MobileAds started") // ← this comes after } }}
Next, split the analytics into "first session" versus "later sessions" and check whether the once-low first-session fill rate now lines up with the others. After the fix, the first-session step became practically invisible across my four apps, and the very first ad slot after a fresh install filled with the same inventory as the rest. Not a dramatic jump in total revenue — a quiet recovery of the first-session price I had been leaving on the table.
Three things that trip you up
Beyond the order itself, three points snagged me repeatedly during the rollout.
First, cases where the ATT dialog does not appear because of the OS rate limit. Once the decision is final for an install, later calls to requestTrackingAuthorization return the settled status immediately without showing a dialog. This is normal; the callback is still always invoked, so the "hang the start off the callback" structure keeps working.
Second, a missing NSUserTrackingUsageDescription in Info.plist. Without it, iOS returns .denied immediately without showing a dialog. If consent is always zero even after you fix the order, suspecting this string first is the fastest path.
Third, the timing at which each mediation partner's SDK reads tracking state on its own. Even after you line GMA SDK start up behind ATT, an adapter configured to read state early can fall out of step. I routed adapter initialization through MobileAds.shared.start as well, leaving no separate early init, and they fell back in line.
The ordering trap is hard to catch in code review and never surfaces in an average-value dashboard. Yet it quietly shaves revenue at the single most important moment — the first session. If ads feel weak only right after a fresh install, start by checking that one thing: whether you start the ad SDK inside the requestTrackingAuthorization callback. I hope it gives a lead to anyone seeing the same symptom.
Share
Thank You for Reading
Antigravity 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.