devsnack

AdMob in Flutter: Banner, Interstitial and Rewarded Ads Without the Crashes

Ad loading is asynchronous and your widget tree is not. A pattern that keeps the two apart, and the frame where interstitials go wrong.

DevSnack11 Aug 2026 · 11 min

Almost every AdMob crash in Flutter is the same bug wearing different clothes: an ad callback fires after the widget that owns it is gone. Loading is asynchronous, your widget tree is not, and nothing in the API stops a listener from calling setState on a disposed State.

Fix that one thing properly and the rest is housekeeping.

Every listener needs a mounted check

lib/ads/banner_slot.dart
class _BannerSlotState extends State<BannerSlot> {
  BannerAd? _ad;
  bool _loaded = false;

  @override
  void initState() {
    super.initState();
    _ad = BannerAd(
      adUnitId: AdIds.banner,
      size: AdSize.banner,
      request: const AdRequest(),
      listener: BannerAdListener(
        onAdLoaded: (_) {
          // The whole bug, in one line. The user can leave this
          // screen while the ad is still in flight.
          if (!mounted) return;
          setState(() => _loaded = true);
        },
        onAdFailedToLoad: (ad, error) {
          ad.dispose();
          if (!mounted) return;
          setState(() => _loaded = false);
        },
      ),
    )..load();
  }

  @override
  void dispose() {
    // Not disposing leaks the platform view. Ten screens in,
    // you are holding ten native banners.
    _ad?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!_loaded || _ad == null) return const SizedBox.shrink();
    return SizedBox(
      width: _ad!.size.width.toDouble(),
      height: _ad!.size.height.toDouble(),
      child: AdWidget(ad: _ad!),
    );
  }
}

Two details in that build. Reserve the exact ad size rather than letting the layout jump when it loads — a banner appearing under a thumb mid-tap is an accidental click, and accidental clicks are invalid traffic. And never put the same AdWidget instance in two places in the tree; one BannerAd belongs to exactly one widget.

Interstitials: preload one, show it, preload the next

Loading an interstitial at the moment you want to show it means either a visible stall or no ad at all. Keep one warm.

lib/ads/interstitial_manager.dart
InterstitialAd? _ad;

void preload() {
  InterstitialAd.load(
    adUnitId: AdIds.interstitial,
    request: const AdRequest(),
    adLoadCallback: InterstitialAdLoadCallback(
      onAdLoaded: (ad) {
        ad.fullScreenContentCallback = FullScreenContentCallback(
          onAdDismissedFullScreenContent: (ad) {
            ad.dispose();
            _ad = null;
            preload();   // warm the next one now, not at show time
          },
          onAdFailedToShowFullScreenContent: (ad, _) {
            ad.dispose();
            _ad = null;
            preload();
          },
        );
        _ad = ad;
      },
      onAdFailedToLoad: (_) => _ad = null,
    ),
  );
}

Future<void> showIfReady() async {
  final ad = _ad;
  if (ad == null) return;   // no ad is not an error — carry on silently
  _ad = null;
  await ad.show();
}

Setting _ad = null before show() is deliberate. A double tap that calls showIfReady twice would otherwise try to show the same ad object twice, which throws.

Never show an ad during a navigation

The classic crash: tap a button, push a route, show an interstitial in the same frame. The ad takes the platform window while the route transition is still running and you get a black screen, a stuck transition, or a hard crash depending on the device.

await Navigator.of(context).push(route);
// After the pop completes, and only on a natural boundary
// like finishing a level or completing an order.
await ads.showIfReady();

The same rule covers app open ads: do not show one while a showDialog or a permission prompt is up.

Test ids are not optional

Clicking your own live ads is invalid traffic, and AdMob suspends accounts for it — including accounts that were only ever testing. Use Google's demo unit ids in debug builds and switch on a compile-time constant so a test id can never ship.

lib/ads/ad_ids.dart
class AdIds {
  static const _debug = bool.fromEnvironment('dart.vm.product') == false;

  static String get banner => _debug
      ? 'ca-app-pub-3940256099942544/6300978111'   // Google demo
      : 'ca-app-pub-XXXXXXXX/YYYYYYYY';
}

Register your own device as a test device as well, so the real unit ids return test fills when you run a release build locally.

Frequency capping is a revenue decision

An interstitial on every screen change earns more per session and fewer sessions. Cap it — once per N actions, with a minimum gap in seconds — and put the numbers in remote config rather than in code, because the right value is an experiment, not a constant.

Rewarded ads are the exception worth building around: the user opts in, so there is no annoyance cost, and eCPM is far higher than banners. If your app has anything worth unlocking, that is where the revenue is.

AdMob Test ID Cheatsheet

Every Google demo unit id — banner, interstitial, rewarded, native, app open — for Android and iOS. Copy-paste, no account.

Open the cheatsheet