Skip to main content

High Match Rate, Low Show Rate: Optimizing AdMob Impressions

 You check your AdMob dashboard and see a promising statistic: a 99% Match Rate. The demand is there; Google has inventory for your users. But immediately next to it, the Show Rate (or Impression Rate) sits at a dismal 15-20%.

For Senior Developers and Ad Ops specialists, this gap represents wasted API calls, unnecessary battery drain, and significant revenue leakage. You are successfully requesting ads, but your application fails to display them to the user.

This discrepancy usually isn't a demand issue; it is an architectural flaw in how the mobile client handles ad caching, lifecycle events, and expiration logic.

The Root Cause: Why Matches Don't Convert to Impressions

To fix the "High Match, Low Show" paradox, we must understand the lifecycle of a programmatic ad.

  1. The Request: Your app requests an ad.
  2. The Fill: AdMob responds with creative assets (Match).
  3. The Wait: The app holds the ad object in memory, waiting for a trigger (level end, screen transition).
  4. The Show: The app calls the .show() method.

The drop-off occurs during "The Wait."

1. The "Hoarding" Problem

Developers often trigger loadAd() in the onCreate or viewDidLoad of every generic screen. If a user navigates through five screens rapidly but only stops on the sixth, you have loaded five ads but shown zero. This destroys your Show Rate and signals to AdMob algorithms that your inventory is low-value, potentially lowering your eCPM.

2. The Expiration Window

AdMob interstitial and rewarded video objects generally have a validity window (typically 60 minutes). If you preload an ad on app launch but the user doesn't reach the trigger point for 65 minutes, the ad object is technically "loaded" but legally "expired." Calling .show() on an expired ad results in a silent failure or an error callback, counting as a Match but not an Impression.

3. Context Detachment

In Android specifically, passing a Context (Activity) to the Ad loader that subsequently gets destroyed (e.g., orientation change or back navigation) can lead to memory leaks or attempts to show ads on detached windows.


The Solution: A Singleton Ad Manager with Expiration Logic

We will implement a centralized "Ad Manager" for both Android (Java) and iOS (Swift). This manager will enforce a strict "Load, Cache, Check, Show" policy.

Key features of this implementation:

  1. Single Instance: Prevents multiple concurrent loads.
  2. Timestamp Validation: Prevents showing expired ads.
  3. Lazy Loading: Only preloads one ad ahead of time.

Android Implementation (Java)

We will use the latest Google Mobile Ads SDK (v22+). We need a singleton that tracks the time the ad was loaded to invalidate it manually if too much time has passed.

AdManager.java

package com.myapp.ads;

import android.app.Activity;
import android.util.Log;
import androidx.annotation.NonNull;
import com.google.android.gms.ads.AdError;
import com.google.android.gms.ads.AdRequest;
import com.google.android.gms.ads.FullScreenContentCallback;
import com.google.android.gms.ads.LoadAdError;
import com.google.android.gms.ads.interstitial.InterstitialAd;
import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback;

public class AppInterstitialManager {

    private static AppInterstitialManager instance;
    private InterstitialAd mInterstitialAd;
    private boolean isAdLoading = false;
    private long loadTime = 0;

    // 1 Hour expiration threshold (in milliseconds)
    private static final long AD_EXPIRATION_THRESHOLD = 3600000; 
    private static final String AD_UNIT_ID = "ca-app-pub-3940256099942544/1033173712"; // Test ID

    private AppInterstitialManager() {}

    public static synchronized AppInterstitialManager getInstance() {
        if (instance == null) {
            instance = new AppInterstitialManager();
        }
        return instance;
    }

    /**
     * Call this during App initialization or after an ad is dismissed.
     */
    public void loadAd(Activity activity) {
        if (isAdAvailable() || isAdLoading) {
            return;
        }

        isAdLoading = true;
        AdRequest adRequest = new AdRequest.Builder().build();

        InterstitialAd.load(activity, AD_UNIT_ID, adRequest,
            new InterstitialAdLoadCallback() {
                @Override
                public void onAdLoaded(@NonNull InterstitialAd interstitialAd) {
                    Log.d("AdManager", "Ad was loaded.");
                    mInterstitialAd = interstitialAd;
                    isAdLoading = false;
                    loadTime = System.currentTimeMillis();
                }

                @Override
                public void onAdFailedToLoad(@NonNull LoadAdError loadAdError) {
                    Log.d("AdManager", "Ad failed to load: " + loadAdError.getMessage());
                    mInterstitialAd = null;
                    isAdLoading = false;
                }
            });
    }

    /**
     * Checks if ad exists and is within the valid time window.
     */
    private boolean isAdAvailable() {
        boolean isExpired = (System.currentTimeMillis() - loadTime) > AD_EXPIRATION_THRESHOLD;
        
        if (mInterstitialAd != null && isExpired) {
            Log.d("AdManager", "Ad expired. Clearing cache.");
            mInterstitialAd = null;
            return false;
        }
        
        return mInterstitialAd != null;
    }

    public void showAd(Activity activity, final Runnable onAdDismissed) {
        if (isAdAvailable()) {
            mInterstitialAd.setFullScreenContentCallback(new FullScreenContentCallback() {
                @Override
                public void onAdDismissedFullScreenContent() {
                    Log.d("AdManager", "Ad dismissed.");
                    // Don't forget to nullify the reference
                    mInterstitialAd = null; 
                    // Preload the next one immediately
                    loadAd(activity);
                    // Continue user flow
                    if (onAdDismissed != null) onAdDismissed.run();
                }

                @Override
                public void onAdFailedToShowFullScreenContent(@NonNull AdError adError) {
                    Log.e("AdManager", "Ad failed to show.");
                    mInterstitialAd = null;
                    if (onAdDismissed != null) onAdDismissed.run();
                }
            });
            
            mInterstitialAd.show(activity);
        } else {
            Log.d("AdManager", "Ad not ready or expired. Moving on.");
            loadAd(activity); // Try to load for next time
            if (onAdDismissed != null) onAdDismissed.run();
        }
    }
}

Implementation Notes

  1. The Expiration Check: isAdAvailable() explicitly checks if the ad is older than 1 hour. AdMob SDK handles this internally to an extent, but explicitly clearing it prevents "Ghost Shows" where the SDK reports a failure to show because the token expired.
  2. Continuity: When showAd fails (or isn't ready), we immediately execute onAdDismissed.run(). This ensures the user isn't stuck waiting for an ad that will never appear.

iOS Implementation (Swift)

For iOS, we use a similar Singleton pattern. Swift's Combine or Closures work well here. We will use a closure pattern to handle the "completion" action, ensuring the app flow continues regardless of ad success.

InterstitialManager.swift

import GoogleMobileAds
import UIKit

class InterstitialManager: NSObject, GADFullScreenContentDelegate {
    
    static let shared = InterstitialManager()
    
    private var interstitialAd: GADInterstitialAd?
    private var loadTime: Date?
    private var onAdDismissed: (() -> Void)?
    
    // Test ID
    private let adUnitID = "ca-app-pub-3940256099942544/4411468910"
    private let expirationInterval: TimeInterval = 3600 // 1 hour
    
    private override init() {}
    
    func loadAd() {
        // Prevent reloading if we have a valid ad
        if isAdAvailable() { return }
        
        let request = GADRequest()
        GADInterstitialAd.load(withAdUnitID: adUnitID, request: request) { [weak self] ad, error in
            guard let self = self else { return }
            
            if let error = error {
                print("Failed to load interstitial: \(error.localizedDescription)")
                return
            }
            
            self.interstitialAd = ad
            self.interstitialAd?.fullScreenContentDelegate = self
            self.loadTime = Date()
            print("Interstitial loaded successfully")
        }
    }
    
    private func isAdAvailable() -> Bool {
        guard let ad = interstitialAd, let loadTime = loadTime else {
            return false
        }
        
        let timeDiff = Date().timeIntervalSince(loadTime)
        if timeDiff > expirationInterval {
            print("Ad expired. Cleaning up.")
            self.interstitialAd = nil
            return false
        }
        
        return true
    }
    
    func showAd(from viewController: UIViewController, completion: @escaping () -> Void) {
        self.onAdDismissed = completion
        
        if isAdAvailable() {
            guard let ad = interstitialAd else {
                completion()
                return
            }
            
            do {
                try ad.canPresent(from: viewController)
                ad.present(from: viewController)
            } catch {
                print("Ad cannot be presented: \(error.localizedDescription)")
                // Ad failed to present, execute completion logic and reload
                self.interstitialAd = nil
                self.loadAd()
                completion()
            }
        } else {
            print("Ad not ready. Proceeding.")
            loadAd() // Try to fill for next time
            completion()
        }
    }
    
    // MARK: - GADFullScreenContentDelegate
    
    func adDidDismissFullScreenContent(_ ad: GADFullScreenContent) {
        print("Ad dismissed.")
        interstitialAd = nil
        loadAd() // Preload next
        onAdDismissed?()
    }
    
    func ad(_ ad: GADFullScreenContent, didFailToPresentFullScreenContentWithError error: Error) {
        print("Ad failed to present: \(error.localizedDescription)")
        interstitialAd = nil
        loadAd()
        onAdDismissed?()
    }
}

Handling Edge Cases: The "What Ifs"

Optimizing show rate requires defensive programming. Here are the specific edge cases handled by the code above:

1. Network Flaps During Show

Sometimes, the device loses connection after the ad is loaded but before it is shown. While many interstitial caches include all assets locally, some configurations (especially video) might require a ping at the moment of impression.

  • Fix: The adDidFailToPresentFullScreenContentWithError (iOS) and onAdFailedToShowFullScreenContent (Android) callbacks are critical. If the show fails, immediately run the completion block so the user flow isn't broken.

2. Scene/Activity Destruction

If the user backgrounds the app for 2 hours, the Operating System might kill the Activity/ViewController but keep the Application process alive.

  • Fix: Our expiration logic (isAdAvailable) checks the timestamp. If the user returns 2 hours later, we discard the stale ad object instantly rather than trying to show it (which would cause a crash or API error) and queue a fresh load.

3. "Pogo-Sticking" Users

If a user enters a screen, triggers loadAd, and leaves immediately, you have wasted a load.

  • Fix: The singleton pattern ensures that if an ad is already loaded (or loading), we don't fire a new request. The ad loaded on Screen A remains available in memory to be shown on Screen B or C.

Conclusion

A high match rate with a low show rate is a clear indicator of inefficient preloading. By moving away from per-screen loading and adopting a Singleton Manager with Time-To-Live (TTL) checks, you align your ad requests with actual user behavior.

This approach achieves three things:

  1. Increases Show Rate: You only request what you are likely to show.
  2. Protects UX: Failed ads default gracefully to content, preventing app freezes.
  3. Boosts eCPM: Ad networks reward apps that reliably convert matches into impressions.

Implement the manager classes above, and you should see your Match and Show rates converge within 48 hours.