●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●DEVICES — Trusted Devices arrives for Team and Enterprise, verifying devices before remote Claude Code sessions●CODE — Claude Code adds org default models, readable session names, and clickable file attachments●MODEL — Claude Sonnet 5 is the default across all plans at $2/$10 per million tokens through August 31●FABLE 5 — Claude Fable 5 returns worldwide from July 1 after export controls lift, with a new cybersecurity classifier●LIMITS — Claude Code weekly usage limits are up 50% through July 13; Claude Science applications close July 15●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●DEVICES — Trusted Devices arrives for Team and Enterprise, verifying devices before remote Claude Code sessions●CODE — Claude Code adds org default models, readable session names, and clickable file attachments●MODEL — Claude Sonnet 5 is the default across all plans at $2/$10 per million tokens through August 31●FABLE 5 — Claude Fable 5 returns worldwide from July 1 after export controls lift, with a new cybersecurity classifier●LIMITS — Claude Code weekly usage limits are up 50% through July 13; Claude Science applications close July 15
I Was Starting the Ad SDK Before the Consent Dialog — Fixing ATT and AdMob Ordering with Claude Code
Starting the ad SDK at launch initializes it before ATT consent, so the IDFA is all zeros and only your measurement goes thin while fill rate stays fine. Here is how I traced the scattered init paths with Claude Code and reshaped them to start ads only after ATT resolves.
✦ Premium Article
While staging a release, I noticed something odd in the AdMob dashboard. Fill rate and eCPM looked completely normal, yet the share of impressions that came through with SKAdNetwork attribution was clearly thinner than before. The ads were serving; only the measurement of those ads was missing.
I ruled out one suspect after another, and the last one standing was initialization order. In the wallpaper app I run as an indie developer, the ad SDK was starting at launch. At that moment the App Tracking Transparency (ATT) dialog had not been shown yet, so the SDK came up with an empty IDFA. I was assembling the measurement foundation before asking for consent.
Let me start from what actually goes missing, then walk through having Claude Code list every init path, folding them into a single "start after ATT" flow, and guarding the order so it can never drift again — all with the real code.
What Goes Missing When You Start MobileAds Before the Dialog
On iOS, an app needs ATT permission to reach the advertising identifier (IDFA). Until the user taps "Allow," the IDFA returned by ASIdentifierManager is an all-zeros value.
The ad SDK reads the available identifiers and measurement settings at init time. If the IDFA is still empty here, SKAdNetwork attribution and the init payloads handed to each mediation partner get assembled on top of a pre-consent state. Serving itself does not need the IDFA, so fill rate does not drop. Only measurement suffers — which is exactly why staring at revenue numbers won't reveal it.
Init order
Ad serving
IDFA
Measurement (SKAdNetwork, etc.)
Ad SDK starts before ATT
Works
Empty at init
Goes thin
Ad SDK starts after ATT resolves
Works
Present if allowed
Lines up
Serving is unchanged, but measurement drifts. That quiet drift was the nastiest part of this ordering bug.
The Broken Order (Before)
The offending code started the ad SDK during SwiftUI app launch. The ATT request was fired later, almost as an afterthought, on some other screen after ads had already appeared a few times.
import GoogleMobileAds@mainstruct WallpaperApp: App { init() { // ❌ Starts the ad SDK at launch. // ATT consent isn't resolved yet, so the SDK inits with an empty IDFA. MobileAds.shared.start() } var body: some Scene { WindowGroup { RootView() } } // ATT was requested later on another screen (= too late)}
init() runs at the very earliest stage of launch. The ATT dialog will not even display until the app becomes active, so this order makes it structurally impossible to avoid finalizing the measurement base before consent.
✦
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
✦You can tell when thin SKAdNetwork measurement — despite healthy fill rate — comes from starting the ad SDK before ATT, and stop guessing at the wrong cause
✦You can move a broken 'start ads at launch' path to a 'start after ATT resolves' shape, with before/after Swift you can paste into your own app
✦You can collapse every ad-start entry point into one and guard it with CI and an assertion so the ordering can never silently regress
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 fix has a single core: start the ad SDK only after ATT has responded — allow or deny. To keep the decision and the start in one place, I collapsed both into a dedicated coordinator.
import AppTrackingTransparencyimport GoogleMobileAds@MainActorfinal class AdConsentCoordinator { private var didStartAds = false /// Call once after the app becomes active. /// Waits for the ATT response, then starts the ad SDK. func requestConsentThenStartAds() async { if #available(iOS 14, *) { // The dialog shows only when undetermined. // If already decided, this just returns the current status — no double prompt. let status = await ATTrackingManager.requestTrackingAuthorization() print("ATT status: \(status.rawValue)") // 0=notDetermined 1=restricted 2=denied 3=authorized } startAdsOnce() } private func startAdsOnce() { guard !didStartAds else { return } didStartAds = true // Only now do we start the ad SDK. Because ATT is resolved, // an allowed user inits with an IDFA and the measurement base lines up. MobileAds.shared.start { status in for (name, adapter) in status.adapterStatusesByClassName { print("adapter \(name): \(adapter.state.rawValue)") } } }}
The call site requests consent only after the app is active in the foreground. Calling it during the inactive launch stage lets it slip past as notDetermined with no dialog, so I anchor it to the moment scenePhase becomes .active.
@mainstruct WallpaperApp: App { @Environment(\.scenePhase) private var scenePhase private let adConsent = AdConsentCoordinator() var body: some Scene { WindowGroup { RootView() .task(id: scenePhase) { // Show ATT after becoming active, then start ads. // The didStartAds guard prevents a double start on re-activation. if scenePhase == .active { await adConsent.requestConsentThenStartAds() } } } }}
Note that MobileAds is the name used in newer Google Mobile Ads SDK versions. If you are still on GADMobileAds.sharedInstance().start(...), the ordering idea is identical — you just move the start call into the coordinator.
Why Fold "Start" Into One Place
Ordering bugs take root when the ad start can be called from several entry points. If it can fire at launch and before the first ad, which one runs first drifts with build and navigation details. Make the coordinator the single entry point, and the invariant "after ATT" is protected by the shape of the code itself.
Have Claude Code List Every Init Path
Counting "where do I start the ad SDK" from memory almost always misses one. I first had Claude Code surface every place the start and the ATT calls were scattered.
Three framing points sharpened the result:
Name the exact targets to search for (MobileAds.shared.start, GADMobileAds, requestTrackingAuthorization).
Report not just where each call lives but when it runs — at launch, or on navigation.
Produce a diff proposal for replacing each site with the coordinator-based path.
Asked in that order, it came back with the real picture, positions included: "started from two entry points — once at launch, once before the ad — and ATT sat behind both of them." Once that is visible, the rest is just folding the start into one. My approach to letting Claude Code touch production code is the same one I described in taking Apple Privacy Manifest in hand as an indie developer: receive changes as diffs first, read them myself, then apply.
Guard So the Order Can Never Drift Again
Fixing it once is not enough — if a later feature drops another MobileAds.shared.start() somewhere, the same bug returns. I added two guards.
One is a debug-time assertion, so a start called from outside the coordinator is caught at runtime.
private func startAdsOnce() { assert(Thread.isMainThread, "Start the ad SDK on the main thread") guard !didStartAds else { assertionFailure("Ad SDK started twice — check whether an entry point was added") return } didStartAds = true MobileAds.shared.start(completionHandler: nil)}
The other is a light CI guard that inspects the path itself. If a start call appears outside the coordinator file, the build stops.
#!/usr/bin/env bash# scripts/check_ad_init.sh — verify the ad SDK starts from exactly one placeset -euo pipefailHITS=$(grep -rn "MobileAds.shared.start\|GADMobileAds" Sources \ | grep -v "AdConsentCoordinator.swift" || true)if [ -n "$HITS" ]; then echo "❌ Ad SDK started from an unexpected place:" echo "$HITS" echo "-> Route it through AdConsentCoordinator." exit 1fiecho "✅ Ad SDK starts only in the coordinator"
You can wire this into a build phase or a Claude Code hook. As an indie developer, I especially value pairing "a structure that can't break" with "a check that keeps it that way." Even in stretched weeks, the check quietly stands guard.
Confirm the Fix Through Staged Rollout
A measurement gap is hard to reproduce locally. Keeping the staged rollout, I compared three things over a few days:
The share of impressions measured via SKAdNetwork (does the thinned metric recover?)
Fill rate and eCPM (is the serving side unharmed — i.e., no side effect?)
Crash rate and ANR (did the reorder break launch? My bar is Crash-free users at 99.7% or higher, ANR under 0.20%)
Requesting ATT right at launch. While the app is inactive, the dialog never shows and you slip past as notDetermined. Request it after the app becomes active.
Killing ads when ATT is denied. Denied means "don't track," not "don't serve." You can still show ads when denied. Do not stop the start — start on the assumption that no identifier is used.
Confusing consent management (UMP, etc.) with ATT ordering. A GDPR consent form and ATT are different things. If you use both, put them in series — consent form, then ATT, then ad SDK start — and start exactly once after everything resolves.
Wrapping Up — Start by Counting the Start Calls
If an app of yours feels like "serving is normal but measurement is thin," the first move is to count how many places start the ad SDK. If there are two or more entry points, one of them is likely running before ATT.
As your first step today, do a full-text search for MobileAds.shared.start (or GADMobileAds) and check the hit count. If it is above one, you can begin right there, by folding it into a single path. I hope this helps with your own implementation, and thank you for reading.
Share
Thank You for Reading
Claude 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.