Capacitor AdMob banner disappears after refresh
In a long session my AdMob banner vanished for good. The Capacitor plugin destroys it when a refresh fails, and my wrapper never noticed. Proven on a device.
Contents
The bottom banner in ASMR Logo Craft Block went missing after a long session. No crash, no error on screen, and it did not come back on its own.
It is the kind of bug that is easy to wave off as no fill. I did not want to guess, so I
had Claude Code look in three places — the game, the shared ads package my games use, and
the native code of the AdMob plugin under node_modules — and then write a test that could
catch it.
The setup
@capacitor-community/admob8.1.0, one anchored banner atBOTTOM_CENTER, sizeADAPTIVE_BANNER- a shared ads package of mine that wraps the plugin, so games ask for "a banner at the bottom" and never call AdMob directly (the arrangement is in One engine, many games)
- Google's sample banner unit,
ca-app-pub-3940256099942544/6300978111, on an Android emulator with a Pixel 9 Pro profile
It is the same banner that once sat on top of the game's buttons; this time the problem was the opposite one.
The game asks for the banner on every screen change and relies on the wrapper to do nothing when it is already up. That part was correct, and it stayed untouched.
What the plugin does when a load fails
The plugin's Android banner code answers a failed load by tearing the banner down. This is its listener in 8.1.0, trimmed:
@Override
public void onAdFailedToLoad(@NonNull LoadAdError adError) {
mViewGroup.removeView(mAdViewLayout);
mAdViewLayout.removeView(adView);
adView.destroy();
mAdView = null;
notifyListeners(BannerAdPluginEvents.SizeChanged.getWebEventName(),
new BannerAdSizeInfo(0, 0));
notifyListeners(BannerAdPluginEvents.FailedToLoad.getWebEventName(),
new AdMobPluginError(adError));
}
The view is removed, destroyed and forgotten. The page gets two events, a size of zero and
bannerAdFailedToLoad, and nothing else. I read the iOS side as well: its
didFailToReceiveAdWithError removes the view and sets the banner's delegate to nil. I did
not run this on iOS; everything measured below is Android.
That covers more than the first load. An AdMob banner reloads itself on a timer, and every one of those refreshes goes through the same listener. One failed refresh, from a network blip or a moment with no ad to serve, and the banner is gone.
How often the banner refreshes
I had assumed somewhere between 30 and 60 seconds. I measured it instead: a fresh launch, online, and the plugin's own log lines for three minutes.
+0:00.0 showBanner
+0:18.9 bannerAdLoaded
+1:33.7 bannerAdLoaded
+2:46.3 bannerAdLoaded
About 75 seconds between loads with Google's test banner. I did not measure a live ad unit. For the bug the exact figure matters little: a session of an hour runs through dozens of refreshes, and any one of them can fail.
Why the banner never came back
The plugin destroying a banner on failure would be survivable if anything asked for a new one. My wrapper had a guard that stopped it from asking. Simplified, it looked like this:
// a simplified version of the old wrapper, not its real code
if (banner.state === 'shown') return; // already up: nothing to do
await AdMob.showBanner(options);
banner.state = 'shown';
The guard had a good reason to exist. On Android, showBanner on a banner that already
exists goes to updateExistingAdView, which loads a new ad into the view and never resolves
the call. Without the early return, every screen change would have left another promise
hanging.
But the wrapper never listened for bannerAdFailedToLoad. After the plugin destroyed the
view, the wrapper's record still said shown, so every later request from the game hit the
early return. The plugin had no banner, the wrapper believed there was one, and the game kept
asking a layer that kept saying yes.
A device test that fails for the right reason
My device tests drive the real app on an emulator through Appium. The banner is a native
view drawn over the WebView, so a test cannot find it among the page's elements. The game
already follows the banner's height through bannerAdSizeChanged to lay itself out, so I
exposed that number to the test build: zero means no banner.
The first version of the test was wrong. It switched the network off, relaunched the app, checked the banner was gone, switched the network back on and waited for it to return. It failed on the old wrapper, which looked like success, until I read the state the report captured:
"consent": { "canRequestAds": false, "status": "unknown" }
Launched offline, the app could not find out whether consent was required, and with a real app ID an unknown consent state requests no ads (the same rule behind a game that showed no ads at all). Ads never started. The test was failing on a different bug, and it would have failed just the same on a fixed wrapper. A test that fails before the fix proves nothing until you know why it failed.
The second version stays launched the whole time:
- Launch online and wait for the banner.
- Go offline and wait for the next refresh to fail and the height to drop to zero.
- Go online and wait up to 150 seconds for the banner to come back by itself, with no screen change to prompt it.
On the old wrapper, step 2 passed after 1 minute 10 seconds offline — a refresh failed and the plugin dropped the banner, exactly as the code said it would. Step 3 failed: two and a half minutes after the network came back the height was still zero. The report's call log showed consent obtained, ads started and a single banner request from the game. This time the failure was the bug itself.
The fix: track what the game wants
The wrapper now keeps two facts apart: the banner the game asked for, and the banner the plugin actually has. When the plugin reports a failed load, the wrapper marks its banner as gone and, if the game still wants one, shows it again after a wait. Here is the idea as a standalone sketch over the plugin's public API:
// a simplified sketch of the fix, not the package's code
let wanted = false, up = false, failures = 0;
let retry: ReturnType<typeof setTimeout> | undefined;
AdMob.addListener(BannerAdPluginEvents.Loaded, () => { failures = 0; });
AdMob.addListener(BannerAdPluginEvents.FailedToLoad, () => {
up = false; // the plugin destroyed its view
if (!wanted) return;
failures += 1;
clearTimeout(retry);
retry = setTimeout(showBanner, Math.min(15_000 * 2 ** (failures - 1), 300_000));
});
async function showBanner() {
wanted = true;
clearTimeout(retry);
if (up) return; // a live view would never answer
await AdMob.showBanner({ adId: BANNER_ID, adSize: BannerAdSize.ADAPTIVE_BANNER,
position: BannerAdPosition.BOTTOM_CENTER });
up = true;
}
The wait starts at 15 seconds and doubles with each failure in a row, up to five minutes; a
successful load resets it. If the game asks again in the meantime, the banner is recreated at
once. Hiding the banner clears wanted and cancels the retry. The real version also runs the
"gone" update through the same queue as every other banner call, so a show still in flight
cannot mark the banner shown after the failure arrived.
One more thing fell out of it. A banner the game asked for before consent allowed ads used to
be dropped, and stayed missing until the next screen change. With wanted kept, it is shown
the moment ads start.
Before and after
The same device test on the fixed wrapper passed. The banner dropped after 65 seconds offline and was back 14 seconds after the network returned. The other eight device tests for the game passed with it.
The unit tests got eight new cases over a fake plugin that behaves like the real one, where a failed load destroys the view. Run against the old wrapper, seven of them fail. The eighth checks that a banner the game hid stays hidden, which the old code already got right.
Two more ways the banner and the wrapper disagreed
The new test exposed a neighbouring gap straight away. An app launched without a network leaves consent unknown, and nothing tried again when the network came back, so that whole session ran without ads. That is now fixed by asking for consent once the connection returns.
The second one turned up later in the same session, in a test for an offline screen. The native
banner survives a page reload. My game reloads its WebView after a player deletes their data,
and my device tests reload it to load saved data. After a reload the plugin still has the old
banner while a fresh wrapper believes there is none. Its showBanner lands on the live view
and never answers, so the wrapper never records the banner as shown, and every later hide is
skipped as "nothing to hide". The banner stayed up over a screen that had asked for none. The
fix is a removeBanner() when ads start, which does nothing when there is no banner. A unit
test for it fails without that one call, and the device tests pass with it.
Neither was the bug I set out to find. Both are the same disagreement: the plugin and the code wrapping it each keep their own idea of whether a banner exists.
Takeaway
If you use @capacitor-community/admob, listen for bannerAdFailedToLoad and treat it as
"the banner is gone": on 8.1.0 the plugin has already destroyed it. Any state you keep about
the banner has to change on that event, or a guard you added for a good reason will keep it
gone for the rest of the session.
And when a new test fails on the old code, read why before you call it proof. My first one failed for a reason that had nothing to do with refreshes, and it would have gone on failing after the fix. The test I kept fails at the exact step the bug lives in, and passes on the fixed version for the reason I expected.