Requesting ads before the SDK finishes initializing: why the same crash came back three times after we added a guard
After we first fixed the "MobileAds.initialize must be called" crash in our live apps, the same crash came back three more times in different shapes. We had a guard that waited for initialization to finish, but a flag set at the start, a flag set on consent alone, and new ad slots that skipped the guard all left gaps. The cause of each case and the results of checking all 23 apps.
The first crash
On September 22, this crash showed up in Crashlytics for version 1.1.22 of our tick photo app. It came from the test robot in Play's pre-launch report.
java.lang.IllegalStateException: MobileAds.initialize must be called
before using the Google Mobile Ads SDK
Our apps use the Google Mobile Ads Next-Gen SDK. We run initialization on a background thread so it doesn't block the main thread, but the screen came up first and requested a native ad before initialization finished. When an ad is requested before initialization is done, this SDK throws the exception above, and the app closes.
The first guard: queue until it's done
Instead of requesting ads right away, we wrote a small class that runs them all once initialization has finished.
public final class AdInit {
private static volatile boolean ready = false;
private static final List<Runnable> pending = new ArrayList<>();
private static final Handler main = new Handler(Looper.getMainLooper());
/** Called from the initialization-complete callback. */
public static void markReady() {
List<Runnable> run;
synchronized (pending) {
ready = true;
run = new ArrayList<>(pending);
pending.clear();
}
for (Runnable r : run) main.post(r);
}
/** Run now if ready, otherwise after it becomes ready. */
public static void ensureThen(Runnable r) {
synchronized (pending) {
if (!ready) { pending.add(r); return; }
}
if (Looper.myLooper() == Looper.getMainLooper()) r.run(); else main.post(r);
}
}
We call markReady() from the completion callback of MobileAds.initialize, and wrapped the first line of the functions that request banner and native ads in AdInit.ensureThen(...). The tick photo app was fixed the same day in 1.1.23, and between September 22 and 25 we added the same guard to our other apps built with Views. Our Compose apps passed a "consent and initialization are done" state down to the UI, and the ad composables checked that state before requesting.
We thought that was the end of it.
Second: a flag that turns on at the "start"
On October 11, we found the same exception in Crashlytics for version 1.0.20 of our reaction-time game. It came from the native ad request on the result screen: 2 crashes from 1 user in the 30 days up to October 14.
The result-screen ad request didn't check initialization at all. The adsInitialized flag that other ad requests checked didn't protect them either. That flag existed so initialization would only start once, so it turned on when initialization started, not when it finished. Its name said "initialized," but it really meant "initialization started." Also, if the user declined ad consent, initialization never started, yet the result-screen ad was still requested.
We switched to the same approach as the tick photo app (a flag set in the completion callback plus a queue) and moved the result-screen ad, the rewarded ad and the exit ad behind it. If consent is declined and there is no initialization, the queued requests stay unrun. This fix is in the next version, which hasn't shipped yet.
Third: a new ad slot that skipped the guard
On the afternoon of October 14, the same exception appeared in version 1.0.22 of our sunrise and sunset app: 1 crash on one Android 16 device.
java.lang.IllegalStateException: MobileAds.initialize must be called ...
at ...ui.components.StartAdSheetKt$StartAdSheet$3$1
This app had LocalAdsConsentReady, which only turns on after consent and initialization are done, and the existing native ad composable checked it. But the start ad sheet and banner slot we built during the September 6 ad UI overhaul didn't check it; they requested ads directly. The guard existed, but the new ad slots didn't go through it.
The banner request was wrapped in a try, so it didn't crash, but when the exception hit, the banner slot stayed empty for that run, and the structure also allowed a request to go out before consent.
We made both wait for the same value.
@Composable
fun StartAdSheet() {
val adsReady = LocalAdsConsentReady.current // consent + SDK initialization done
val allowed = remember { StartAdGate.shouldShow(ctx) }
if (!allowed || !adsReady) return
// ... NativeAdLoader.load from here on
}
Checking all 23 apps
Since it had come back in one app, we assumed it could be in others and checked the source and the last 30 days of Crashlytics for all 23 live apps.
- 2 to fix: the sunrise and sunset app (above), and our AdMob earnings dashboard app. The dashboard app had 0 crashes in Crashlytics, but its "ads on" flag turned on as soon as consent alone finished. Initialization was only started in the background from
Application, with no signal for when it finished, so if consent finished before initialization, the start ad and the exit ad preload could run ahead of it. We added the sameAdInit.ensureThenas the tick photo app and moved the "ads on" flag, the exit ad preload and the rewarded ad behind its completion callback. - 1 already fixed: the reaction-time game (the second case, already in the next version).
- 20 fine: the 15 View apps had the guard on the first line of their request functions, and in the 5 Compose apps the ad composables checked the completion state.
We got one thing wrong along the way. At first a name was missing from our search, so we counted the 5 Compose apps as having the same problem. Opening the source again showed they did the same job under a different name, and we corrected it. A check that searches by name is wrong as soon as one name differs.
For both apps, we confirmed on a Galaxy Note10+ that the start ad showed and nothing crashed, with both the debug build and the R8-enabled release build, then shipped the sunrise and sunset app 1.0.23 and the dashboard app 1.0.54 on October 14.
Takeaways
- With the Next-Gen SDK, ad requests must go out after initialization finishes. If you initialize in the background, the screen really can come up first.
- Set the "ready" flag in the completion callback. Set it when initialization starts and the name is right but it blocks nothing.
- Check the path where consent is declined and initialization never runs. On that path, no request should go out at all.
- If the guard is attached slot by slot, it's easy to miss when a new ad slot is added. If the request function itself waits, as in our View apps, new slots go through the guard automatically.
- This crash showed up once in 30 days. It's a rare ordering problem, so when even one shows up, it's faster to open every app with the same structure.
The sunrise and sunset app in this post is SunGlow (Google Play).