EEltaCrew
Home › Blog

Requesting ads before the SDK finishes initializing: why the same crash came back three times after we added a guard

Published 2026-10-15Measured 2026-10-14About 5 min readelta · EltaCrew

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.

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

The sunrise and sunset app in this post is SunGlow (Google Play).

AndroidAdMobGoogle Mobile Ads SDKCrashlyticsCrashes

Related

A crash from one line of image downsampling: inSampleSize starts at 0To save memory, we changed the document thumbnails in our QR scanner app to load downsampled. In the next version, the app closed every time the document list opened. The cause was that BitmapFactory.Options.inSampleSize defaults to 0. The code we changed, the crash data, the fix, and how we checked for the same mistake elsewhere.The crashes Crashlytics didn't show: it only sees the builds you added it toThree days after adding Firebase Crashlytics to 21 apps, we found a user-facing crash in Play Console first. Crashlytics only reports from builds that include the SDK, and crashes from older versions still on users' phones pile up only in Play vitals. Results from checking all 21 apps, and the order we check in now.The day a sync deleted 8,802 stores: one renamed API field and a 100-row listIn our lottery retailer map app, a sync turned every store name into "Unknown" and wiped the 8,802 stores bundled with the app. Three causes — a renamed field in a public data API, a sync that deleted rows based on only the first 100 results, and a geocoder reading keys that do not exist — and how each was fixed.