I launched PyMaster — a gamified Python learning app and everything looked fine — until my AdMob match rate dropped to 0.5%.
Here’s the mistake that caused it (and how to avoid it).
A few months in, I shipped an update I was proud of. Within days, my AdMob dashboard was showing something I didn't expect: ~2,000 requests. ~10 impressions.
That wasn't a bad ad day. That was a self-inflicted wound.
Here's what I got wrong, what it cost me, and the smarter path I found on the other side.
The Setup: Native Ads in a Scrollable List
My home screen is a vertical list — chapters, zones, locked content. Classic stuff.
I slotted a native ad tile into the list. It worked beautifully in testing. Loaded, rendered, sat quietly between content.
So I got ambitious. I added logic to retry loading if an ad failed — because why waste a slot? If AdMob couldn't fill it, at least try again, right?
The implementation looked like this:
// native_ad_tile.dart (simplified)
class NativeAdTile extends StatefulWidget {
@override
State<NativeAdTile> createState() => _NativeAdTileState();
}
class _NativeAdTileState extends State<NativeAdTile> {
NativeAd? _nativeAd;
bool _isLoaded = false;
bool _adFailed = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Retry logic: reload if not yet failed
if (_nativeAd == null && !_adFailed) {
_loadAd();
}
}
void _loadAd() {
_nativeAd = NativeAd(
adUnitId: 'your-ad-unit-id',
listener: NativeAdListener(
onAdLoaded: (ad) => setState(() => _isLoaded = true),
onAdFailedToLoad: (ad, error) {
ad.dispose();
setState(() => _adFailed = true); // ← This was missing initially
},
),
)..load();
}
@override
Widget build(BuildContext context) {
if (_adFailed) return const SizedBox.shrink();
if (!_isLoaded) return _buildSkeleton();
return AdWidget(ad: _nativeAd!);
}
}
Clean. Defensive. I was proud of it.
Then I shipped the update and forgot to ask one question:
What happens when AdMob has zero ads to serve?
Zero-fill regions broke my ad logic.
AdMob fill rates are not uniform across regions. In some markets — including places like Burkina Faso — native ad inventory can be extremely limited or even unavailable. In those cases, AdMob will accept the request but return a no-fill error.
My retry logic didn’t account for that. It assumed an ad would eventually load — so it kept trying.
Every scroll. Every didChangeDependencies call. Every widget rebuild.
The list had multiple ad slots. Each one repeatedly requesting ads.
The result?
~2,000 ad requests. ~10 impressions.
That’s a 0.5% fill rate.
This kind of request-to-impression imbalance can negatively impact performance signals like match rate and eCPM over time. When your app generates many requests but very few impressions, it may indicate inefficient ad loading or poor integration patterns.
The effect compounds quietly in the background — while everything looks fine on the surface.
The Lesson: Think in Request/Impression Ratio
When you're building ad integrations, the metric that matters most isn't revenue per impression.
It's request/impression ratio.
A ratio far above 1.0 is a red flag. It tells AdMob's systems that your integration is spammy, unreliable, or poorly targeted. This can reduce the chances of receiving high-value ads. Your eCPM drops. Your fill rate drops further. It compounds.
The golden rule for ad requests:
- Make one request per ad slot per session
- If it fails → mark it as failed, collapse the space, and move on
- Do not retry indefinitely on failure
- Do not reload on every widget lifecycle event
// ✅ The defensive pattern
onAdFailedToLoad: (ad, error) {
ad.dispose();
if (mounted) {
setState(() {
_adFailed = true; // Mark permanently failed
_isLoaded = false;
});
}
// No retry. No loop. Just fail gracefully.
},
// ✅ Collapse space on failure — don't show an empty box
@override
Widget build(BuildContext context) {
if (_adFailed) return const SizedBox.shrink(); // Zero height. Zero cost.
// ...
}
Best Practices for Native Ads in Flutter (The Hard-Won List)
1. Always use test IDs in debug mode
if (kDebugMode) {
adUnitId = Platform.isAndroid
? 'ca-app-pub-3940256099942544/2247696110'
: 'ca-app-pub-3940256099942544/3986624511';
}
Never test with production IDs. Never.
2. Gate production loading strictly
// If the production ID is missing, do nothing.
if (adUnitId == null || adUnitId.isEmpty) {
setState(() => _adFailed = true);
return;
}
An empty ad is better than a test ad in production. An empty ad is better than a crash.
3. Use AutomaticKeepAliveClientMixin to prevent reload storms
In a scrollable list, widgets get disposed and recreated as you scroll. Without keep-alive, every scroll past an ad tile triggers a new ad request.
class _NativeAdTileState extends State<NativeAdTile>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context); // Required
// ...
}
}
4. Load the ad once — whether you use initState or didChangeDependencies, just make sure it’s properly guarded so you don’t trigger multiple requests.
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_nativeAd == null && !_adFailed) {
_loadAd(); // The guard prevents double-loading
}
}
5. Always dispose
@override
void dispose() {
_nativeAd?.dispose();
super.dispose();
}
Miss this once and you'll have memory leaks and phantom requests running in the background.
I Pulled the Plug. And It Felt Good.
After diagnosing the damage, I made the call: remove native ads entirely.
Within 24 hours of shipping that update:
- Request count dropped to near zero
- The relief was immediate and visible in the dashboard
- My match rate started recovering
Native ads in a scrollable list with unknown regional fill rates were doing more harm than good.
What I Replaced Them With
Here's the clever bit.
Instead of an ad tile, I now have an in-house promotional tile.
Same slot. Same height. Same visual weight in the list.
But it promotes PyMaster Pro — my own upgrade.
// No AdMob. No fill rate. No requests. Just yours.
class ProUpgradeTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 90,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.amber, Colors.orange]),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.star, color: Colors.white),
Text("Go Pro — Unlock All Chapters"),
TextButton(onPressed: () => _openPaywall(), child: Text("Upgrade")),
],
),
);
}
}
The slot that was burning my request ratio and damaging rankings is now a direct conversion surface.
100% fill rate. 0 AdMob requests. You own the real estate.
The TL;DR
| What I Did Wrong | What to Do Instead |
|---|---|
| Retried ad load on every rebuild | Mark failed, collapse, move on |
| No regional fill-rate awareness | Assume zero fill is possible anywhere |
| Native ads in a high-scroll list | Use AutomaticKeepAliveClientMixin
|
| Measured success by impressions | Measure request/impression ratio |
| Kept native ads when they hurt | Know when to cut your losses |
Final Thought
Stress testing ad integrations isn't just about "does the ad show."
It's about how your app behaves when it doesn’t.
Simulate real failure conditions — poor connectivity, no-fill responses — and watch what your UI actually does.
The bugs that hurt aren’t the visible ones. They’re the silent loops in the background, draining performance while everything looks fine on the surface.
Code defensively. Watch your ratios.
And design for failure — not just success.
I’m Bhanu, solo founder at Devanshu Studio, building PyMaster — a gamified Python learning app for mobile.
If you're learning Python on mobile, it's built for you → Check it out on the Play Store
Tags: flutter admob mobile indie-dev showdev android monetization

Top comments (0)