Keeping the AdMob Waterfall While Adding Bidding: A Decision Record from Four Live Apps
How I added bidding on top of an existing AdMob waterfall across four live iOS apps: the ATT and init ordering, ad-gate design, and the metrics to watch.
I was adding mediation partners to four iOS apps. Opening the group list in AdMob, I found bidding partners and the usual waterfall rows sitting side by side on the same screen.
Should one of them go? Or could they stay together?
I couldn't decide on the spot, so I closed the settings. This article records what I thought about then and what I ended up deciding.
It reflects the situation as of October 2026. Screen and option names change often, so please confirm against the official documentation before you act.
The Short Answer: I Added, I Did Not Replace
I kept the waterfall and layered bidding on top.
Liftoff, InMobi, and Unity Ads went in as bidding partners, added to the shared iOS rewarded group (RWD-iOS) used by all four apps
AppLovin MAX stayed a waterfall, added across 20 groups, with the optimization toggle on so eCPM-based adjustment runs dynamically
Judging the switch happens on per-network revenue logs, not on feel
"Migrate to bidding" is the common phrasing, but in real operation the period of running both is longer than a clean cutover. The next section explains why.
✦
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 will be able to decide, before touching either side, what to check in your own app to choose between waterfall and bidding
✦You will be able to prevent the silent bid failures caused by getting the ATT prompt and ad SDK initialization out of order
✦You will be able to log revenue per ad network in Firebase and compare before and after a switch on the same yardstick
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.
Comparing the Two Methods from the Operator's Seat
I'll leave the mechanics to the official docs and list only what you touch in the console and what tends to break.
Aspect
Waterfall
Bidding
Who sets the price
You (floors and ordering)
Each network, by bid
What you touch
Order, floor prices, grouping
Almost nothing (enable partners, payment setup)
How it fails
Floors drift from the market and cheap ads fill first
Init or tracking-consent ordering is off and bids never appear
How easy to diagnose
Read the ordering and you can trace it
Hard without logs
Manual effort
High (assumes periodic review)
Low
Bidding costs less effort, but it is also the method where you least notice that something has gone wrong. If you switch without per-network revenue in hand, you can never say afterward whether things improved.
That was the main reason I ran both. You need the yardstick first. Replace before it exists and no evidence for the decision remains.
ATT and Initialization Order: The First Trap That Empties Bids
On iOS, starting the ad SDK before the tracking-consent prompt can send requests without the identifier that bidding needs. In my apps, ATTrackingManager.requestTrackingAuthorization runs first, and MobileAds is initialized only after it completes.
import AppTrackingTransparencyimport GoogleMobileAds// Call when the app becomes active (the prompt won't appear while inactive)func startAdsAfterTrackingPrompt() { ATTrackingManager.requestTrackingAuthorization { _ in // Regardless of the answer, start the SDK once the prompt has closed DispatchQueue.main.async { MobileAds.shared.start(completionHandler: nil) } }}
Why write it this way? The answer can be allow or deny; either is fine. What matters is that the SDK reads identifiers after the user's choice is settled. When a user denies, ads still serve but bid prices fall. That is by design, not a defect.
Get this wrong and nothing visibly breaks. Ads keep appearing, and only the revenue quietly thins out.
The step before it: consent messaging for the EU and UK
The code above handles ATT only. If your app serves ads to users in the EU or UK, Google's UMP (User Messaging Platform) consent step comes first. The official docs describe the flow like this: update the consent information at every launch, present the form if one is required, and check canRequestAds before requesting any ad.
import GoogleMobileAdsimport UserMessagingPlatformimport UIKit// Update consent on every launch, show the form if needed, then go on to ATT → SDK startfunc gatherConsentThenStartAds(from vc: UIViewController) { ConsentInformation.shared.requestConsentInfoUpdate(with: RequestParameters()) { error in // Don't stop on an update error: the previous consent state may still allow ads ConsentForm.loadAndPresentIfRequired(from: vc) { _ in guard ConsentInformation.shared.canRequestAds else { return } DispatchQueue.main.async { startAdsAfterTrackingPrompt() // the ATT → MobileAds start shown above } } }}
canRequestAds returns false until you have called the consent update at least once, so forgetting that call shows up as "no ads at all". That is easier to notice than a missing ATT step, but requests that fire while the consent form is still on screen still cost revenue.
You can also show the ATT prompt through AdMob's IDFA message instead. In that setup you need to avoid calling it twice, and the details vary by SDK version, so please check the official privacy and consent page just before you implement. One honest caveat: I wrote this Swift snippet from the official API names and flow, and I did not run it on a device for this article.
Three Fragile Spots on the Implementation Side
More mediation puts more load on the code that shows ads. Here are three places I revisited in the Android update.
First, the back-button ad gate. Written as nested ifs, the priority between ads disappears into the shape of the code. I made the gates parallel and independent, and expressed priority as list order.
interface AdGate { val name: String fun isEligible(): Boolean // Display conditions (including the ad-free check) fun show(onDone: () -> Unit)}class BackPressGates(private val gates: List<AdGate>) { // Priority is the list order. Read this and you see the whole picture fun onBackPressed(proceed: () -> Unit) { val target = gates.firstOrNull { it.isEligible() } if (target == null) proceed() else target.show(proceed) }}
Second, simultaneous full-screen surfaces. The paywall prompt, the review request, and the rewarded-ad invitation each work correctly alone. The moment they overlap, things break visually. I added one layer (ModalGate) that manages central modals in a single place, with a rule that only one shows at a time.
Third, the ad-free decision. Purchase-based and reward-based temporary ad removal both pass through a single combined check, isAdFree || isRewardAdFree. Multiple entry points to that decision mean more loopholes each time you add mediation. I wrote up that idea in the article on keeping billing state as a single source of truth.
Log Per-Network Revenue Before You Switch
This is the linchpin of running both. Every time an ad is shown, record which network it came from and how much it paid into Firebase.
import com.google.android.gms.ads.OnPaidEventListenerimport com.google.android.gms.ads.rewarded.RewardedAdimport com.google.firebase.analytics.FirebaseAnalyticsimport com.google.firebase.analytics.logEvent// For each served ad, record the source network and its revenuefun RewardedAd.trackPaidEvents(analytics: FirebaseAnalytics, placement: String) { onPaidEventListener = OnPaidEventListener { adValue -> val source = responseInfo.loadedAdapterResponseInfo?.adSourceName ?: "unknown" analytics.logEvent("ad_revenue_by_source") { param("ad_platform", "admob") param("ad_source", source) // Which network won param("ad_format", "rewarded") param("ad_unit_name", placement) param(FirebaseAnalytics.Param.VALUE, adValue.valueMicros / 1_000_000.0) param(FirebaseAnalytics.Param.CURRENCY, adValue.currencyCode) } }}
I deliberately did not name the event ad_impression. According to the Firebase documentation, when AdMob is linked to Firebase, ad_impression is logged automatically, and the automatic version carries no value or currency in the default integration. Adding a manual event with the same name and a revenue value may count impressions twice. The docs don't address that duplication, so I can't state it as fact. For comparing networks, though, a separate event name saves you from doubting the numbers later. If you want to pass value to Google Ads, the situation differs: log ad_impression yourself in that case, and confirm the counts in DebugView first.
With ad_source recorded, you can line up "times bidding partners won," "times the waterfall side won," and "average price for each" over the same period and the same ad unit. The switching decision comes down to that one table.
Before and after a switch, I watch these five things.
Metric
Why it matters
Win rate and average price per network
Confirms the benefit of running both by who is actually earning
Show rate per request
First check that bids are not coming back empty
Time until an ad appears
Bidders compete at once, so latency is easy to feel
Crash-free users
Adding adapters adds SDKs, so check stability first
ANR rate
Verifies heavy init work isn't blocking the main thread
I release in staged steps of 5% → 25% → 50% → 100%, and only move to the next step while Crash-free users stays at or above 99.7% and ANR stays below 0.20%. Even if revenue looks better, stability comes first.
What I Hand to Antigravity, and What I Don't
Work that went well with the agent:
Implementing the delivery logging above and wiring it into existing code
Structural cleanup such as making the ad gates parallel
Handling test ad unit IDs (writing them in AGENTS.md helps avoid misusing production IDs)
What I did not hand over is anything in the console that affects revenue. Enabling partners and payment profiles (for each of Unity Ads, Liftoff, and InMobi) tie directly to money and rights, so I worked through them with my own eyes.
The agent speeds up implementation, but the responsibility for deciding what goes to bidding and what doesn't can't be handed off. That part stays with me.
For related implementation, you may also like AdMob integration in Flutter and Firebase project setup.
Your Next Step
Try giving yourself about four weeks of running both. During that time, do three things.
Record revenue with ad_source every time an ad is shown
Confirm the order is consent message → ATT prompt → SDK start
Roll out in stages while watching Crash-free users and ANR
It is not too late to decide what to keep and what to reduce once the yardstick is in place. Declining to rush a replacement is, in my experience, a perfectly valuable choice in operation. Looking back, the pause in front of the console may not have been indecision at all, but a sign that I was missing a yardstick.
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.