← All posts

7 min read

Ad Monetization in Flutter Without Ruining the User Experience

How I add ad monetization to Flutter apps with google_mobile_ads — formats, placement, frequency caps, consent, mediation and paywalls — without driving users away.

  • Flutter
  • Ads
  • Monetization
Cover illustration for Ad Monetization in Flutter Without Ruining the User Experience

Everyone has used the app that shows a full-screen ad the moment it opens, another one when you tap “Back”, and a banner that sits right where your thumb wants to go. You probably uninstalled it. So did everyone else.

Ads aren’t the problem. Badly placed ads are. Done well, they pay for a free tier and nobody resents them. Done badly, they trade a few cents today for a one-star review and a churned user tomorrow.

On SafeMom I integrated multiple ad SDKs, including Facebook and TikTok, alongside an Adapty paywall. This post covers the rules I follow when adding ads to a Flutter app, with code for the parts that are easy to get wrong.

Pick the right format for the moment

The google_mobile_ads plugin supports the main AdMob formats. Each one fits a different kind of moment:

  • Banner. Small, persistent, low revenue per impression. Fine at the bottom of a content screen. Never next to buttons people tap a lot.
  • Interstitial. Full screen, high impact. Only at a natural break: after finishing a level, after saving a document, between articles. Never in the middle of a task.
  • Rewarded. The user chooses to watch in exchange for something: an extra scan, a hint, a premium feature for a day. This is the friendliest format because the user is in control.
  • Native. Styled to match your UI, shown inside a feed. Done right it blends in; done wrong it looks like a trick. Always label it clearly as an ad.
  • App open. Shown when the app comes to the foreground. It can work for some apps, but it’s the easiest to abuse. If you use it, skip it on the very first launch and when the user returns after only a few seconds.

My default for a new app is a rewarded ad as the main earner, plus maybe a banner on a content screen. I add interstitials only if the numbers say we need them.

Placement and frequency capping

The rule I keep coming back to is simple: never interrupt someone who is in the middle of something. Checkout, form entry, onboarding, the first session: all ad-free.

Even at natural breaks, frequency matters. I wrap interstitials in a small controller that enforces a minimum gap between shows and skips them early in a session:

class InterstitialController {
  InterstitialController({required this.adUnitId});

  final String adUnitId;
  InterstitialAd? _ad;
  DateTime? _lastShown;
  int _naturalBreaks = 0;

  static const _minGap = Duration(minutes: 3);
  static const _skipFirstBreaks = 2;

  void preload() {
    InterstitialAd.load(
      adUnitId: adUnitId,
      request: const AdRequest(),
      adLoadCallback: InterstitialAdLoadCallback(
        onAdLoaded: (ad) => _ad = ad,
        onAdFailedToLoad: (error) => _ad = null,
      ),
    );
  }

  /// Call at a natural break. Returns immediately if we shouldn't show.
  void maybeShow() {
    _naturalBreaks++;
    final ad = _ad;
    if (ad == null || _naturalBreaks <= _skipFirstBreaks) return;
    if (_lastShown != null &&
        DateTime.now().difference(_lastShown!) < _minGap) return;

    ad.fullScreenContentCallback = FullScreenContentCallback(
      onAdDismissedFullScreenContent: (ad) {
        ad.dispose();
        preload(); // get the next one ready
      },
      onAdFailedToShowFullScreenContent: (ad, error) {
        ad.dispose();
        preload();
      },
    );
    _ad = null;
    _lastShown = DateTime.now();
    ad.show();
  }
}

The numbers here are placeholders, not a recommendation. Tune them per app, ideally from remote config so you can adjust without shipping an update.

Preload, never block

Notice that maybeShow() never waits for an ad. If one isn’t loaded, the user just carries on. The worst ad experience is a spinner that says “Loading…” while the user waits for an ad they didn’t want.

Load the next full-screen ad as soon as the previous one is dismissed, and keep loaded ads around rather than requesting them on demand. Full-screen ads do expire after a while, so if your breaks are far apart, track the load time and reload stale ones.

Rewarded ads follow the same pattern. The reward is only granted in the callback, never just because show() was called:

rewardedAd.show(
  onUserEarnedReward: (ad, reward) {
    unlockExtraScan(); // grant only here
  },
);

If no rewarded ad is available, hide or disable the “Watch an ad” button instead of letting the user tap it and get nothing.

If you have users in the EEA or UK, you need GDPR consent before serving personalized ads. Google’s User Messaging Platform (UMP) is built into google_mobile_ads through the ConsentInformation and ConsentForm APIs, so you don’t need a separate package.

I run consent before initializing ads:

Future<void> initAdsWithConsent() async {
  final completer = Completer<void>();

  ConsentInformation.instance.requestConsentInfoUpdate(
    ConsentRequestParameters(),
    () {
      ConsentForm.loadAndShowConsentFormIfRequired((formError) {
        completer.complete();
      });
    },
    (error) => completer.complete(), // fall through; canRequestAds decides
  );

  await completer.future;

  if (await ConsentInformation.instance.canRequestAds()) {
    await MobileAds.instance.initialize();
  }
}

Two more things that are easy to miss:

  • A privacy options entry point. Users must be able to change their choice later. Check getPrivacyOptionsRequirementStatus() and, if required, put a “Privacy settings” item in your settings screen that calls ConsentForm.showPrivacyOptionsForm.
  • iOS App Tracking Transparency. To use the IDFA on iOS you need the ATT prompt and an NSUserTrackingUsageDescription in Info.plist. UMP can show an explainer message before the ATT dialog if you configure it in the AdMob console; otherwise a package like app_tracking_transparency triggers the prompt. Ask after consent, not on cold start before the user has seen anything.

Test ads and test devices

This one can get your account suspended: never click your own live ads, and never let your QA team do it either. AdMob treats it as invalid traffic.

During development, use Google’s published test ad unit IDs (for example, the Android interstitial test ID ca-app-pub-3940256099942544/1033173712). I keep the real IDs in build-flavor config so debug builds can’t use them by accident.

When you need to test real ad units, for example to check mediation, register your devices as test devices:

MobileAds.instance.updateRequestConfiguration(
  RequestConfiguration(testDeviceIds: ['YOUR_DEVICE_HASH']),
);

The device hash is printed in the logs the first time you request an ad on that device.

Mediation

Mediation lets AdMob ask other networks to fill the same ad slot, which usually improves fill rate and revenue. Each network needs its own adapter package plus native setup on Android and iOS, and each adds to app size and to the surface area for SDK bugs.

My rule: start with AdMob alone. Add networks one at a time, and only when you can measure whether each one earns its place. Every SDK you add is another thing to update, another privacy disclosure, and another possible crash in your reports.

Ads and a paywall, together

The best-performing setups I’ve worked on treat ads and subscriptions as one system: the free tier has ads, and removing ads is one of the clearest reasons to upgrade. On SafeMom the paywall ran through Adapty; with RevenueCat the approach is the same (I covered that in RevenueCat subscriptions in Flutter).

A few rules make it work:

  • Premium users see zero ads. Check entitlement before loading, not just before showing, so you don’t waste requests.
  • Don’t stack interruptions. Never show an interstitial and a paywall back to back. Pick one per break.
  • Rewarded ads can preview premium. “Watch an ad to unlock this for today” lets users try a feature they might later pay for.

Measure retention, not just revenue

Ad revenue shows up in a dashboard the next day. The damage shows up weeks later in retention. If you only watch eCPM, you will keep adding ads until the app is empty.

What I track when ads go live:

  • Day-1, day-7 and day-30 retention, compared with the period before ads.
  • Session length and sessions per user.
  • Paywall conversion: does a new ad placement push upgrades up, or users out?
  • Ad-related crashes and ANRs. Ad SDKs are a common source of both, so crash reporting needs to be in place first. See crash reporting and monitoring in Flutter.

If you can, roll out a new placement to part of your users through remote config and compare. A placement that earns a bit more but costs users is a loss.

Takeaways

  • Match the format to the moment: rewarded ads when the user opts in, interstitials only at natural breaks.
  • Cap frequency, skip the first session, and keep ads away from critical flows.
  • Preload ads and never make the user wait for one.
  • Handle UMP consent and iOS ATT before requesting ads, and give users a way to change their choice.
  • Use test IDs and test devices; never click your own live ads.
  • Treat ads and your paywall as one system, and judge every change by retention, not just revenue.

Good ad monetization is mostly restraint, and your users will notice it.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).