Skip to main content

Solved: AdMob 'Ad Serving Is Limited' Due to Invalid Traffic

 You open your AdMob console to check yesterday’s earnings, expecting a steady climb. Instead, you see a flatline and a red banner at the top of the screen: "Ad serving is limited. The number of ads you can show has been limited."

For Indie Developers and Monetization Managers, this is a crisis. It effectively pauses revenue for 30 days or more. While Google's support documentation is vague, the technical reality is binary: your app is sending traffic patterns that look like bot activity or accidental clicks.

This article bypasses the generic "check your traffic" advice. We will implement a rigorous, code-level defense strategy to eliminate invalid traffic (IVT) using React Native (applicable logic for Swift/Kotlin), focusing on Layout Shift prevention and Environment Segregation.

The Root Cause: Why Google Flagged You

To fix the problem, you must understand the algorithm that flagged you. Google’s AdMob AI prioritizes the advertiser's experience over the publisher's revenue. The "Limited" status is usually triggered by two specific technical failures:

1. Cumulative Layout Shift (CLS)

This is the most common reason for "Accidental Clicks."

  1. The user attempts to click a "Next Level" button.
  2. An ad banner loads asynchronously milliseconds before the tap.
  3. The UI shifts down to accommodate the banner.
  4. The user's finger lands on the ad instead of the button.

AdMob tracks the time-delta between the ad rendering (impression) and the click. If this delta is consistently low (under 1 second), it is flagged as IVT.

2. Polluted Development Data

If you or your QA team run the app on real devices without explicitly configured Test IDs, every interaction you make counts as a fraudulent impression or click. Google’s algorithms quickly detect a single device generating high click-through rates (CTR) from a static IP.

The Technical Solution

We will implement a SafeAdContainer in React Native using TypeScript. This component solves the problem by enforcing Strict Dimension Reservations and Environment Guarding.

Prerequisite: Correct Configuration

Ensure you are using react-native-google-mobile-ads.

Step 1: Environment-Agnostic ID Management

Never hardcode production Ad Unit IDs directly into your components. You need a config layer that serves Test IDs automatically when in development. This prevents 100% of self-click bans.

// config/ads.ts
import { TestIds } from 'react-native-google-mobile-ads';

// Detect if we are in a development environment
const IS_DEV = __DEV__; 

interface AdUnitConfig {
  banner: string;
  interstitial: string;
}

const productionIds: AdUnitConfig = {
  banner: 'ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy', // Your Real ID
  interstitial: 'ca-app-pub-xxxxxxxxxxxxxxxx/zzzzzzzzzz',
};

export const adUnitIds: AdUnitConfig = {
  banner: IS_DEV ? TestIds.BANNER : productionIds.banner,
  interstitial: IS_DEV ? TestIds.INTERSTITIAL : productionIds.interstitial,
};

Step 2: The Anti-CLS Wrapper Component

This is the core fix. We create a wrapper that reserves the exact pixel height of the ad before the ad loads. This ensures the UI never jumps, preventing accidental clicks.

We also implement a "Skeleton" state to improve perceived performance while the ad network negotiates the bid.

// components/SafeBannerAd.tsx
import React, { useState } from 'react';
import { View, StyleSheet, Text, Platform } from 'react-native';
import { BannerAd, BannerAdSize, AdEventType } from 'react-native-google-mobile-ads';
import { adUnitIds } from '../config/ads';

interface SafeBannerAdProps {
  size?: BannerAdSize;
}

export const SafeBannerAd: React.FC<SafeBannerAdProps> = ({ 
  size = BannerAdSize.ANCHORED_ADAPTIVE_BANNER 
}) => {
  const [isAdLoaded, setIsAdLoaded] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Standard height for standard banners to prevent layout shift.
  // For adaptive banners, you might need dynamic calculation, 
  // but a min-height reservation is critical.
  const PRESET_HEIGHT = 60; 

  if (error) {
    // Fail silently or log to Crashlytics - do not show broken UI
    return null;
  }

  return (
    <View style={[styles.container, { minHeight: PRESET_HEIGHT }]}>
      
      {/* 
         The Ad Component 
         We listen to life-cycle events to verify valid impressions.
      */}
      <BannerAd
        unitId={adUnitIds.banner}
        size={size}
        requestOptions={{
          requestNonPersonalizedAdsOnly: true,
        }}
        onAdLoaded={() => {
          setIsAdLoaded(true);
        }}
        onAdFailedToLoad={(err) => {
          console.error('AdMob Error:', err);
          setError(err.message);
          setIsAdLoaded(false);
        }}
      />

      {/* 
         Skeleton / Placeholder
         This renders BEHIND the ad or disappears when loaded.
         Crucially, it holds the layout structure. 
      */}
      {!isAdLoaded && (
        <View style={[styles.skeleton, { height: PRESET_HEIGHT }]}>
          <Text style={styles.skeletonText}>Advertisement</Text>
        </View>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    width: '100%',
    alignItems: 'center',
    justifyContent: 'center',
    // Vertical margin separates ad from interactive buttons
    marginVertical: 20, 
    backgroundColor: 'transparent',
  },
  skeleton: {
    position: 'absolute',
    width: '100%',
    backgroundColor: '#f0f0f0',
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 8,
  },
  skeletonText: {
    color: '#a0a0a0',
    fontSize: 10,
    fontWeight: '600',
    letterSpacing: 1,
    textTransform: 'uppercase',
  },
});

Deep Dive: Why This Fix Works

Visual Isolation

By adding marginVertical: 20 in the styles, we create a "safe zone." Google's policies explicitly state that ads must be distinguishable from app content. If a user is tapping a FlatList item and the ad is immediately adjacent without padding, the "fat finger" error rate skyrockets. This padding physically separates intention from accident.

Layout Stability

The minHeight property is the MVP of this solution. Without it, the View has a height of 0. When the ad fills (taking 200-800ms), the view expands to 60px/100px/250px. All content below it is pushed down.

By reserving that space initially (via the Skeleton), the UI is static. The ad simply fades in. There is no shift. Therefore, a click immediately after load is statistically likely to be intentional, raising your traffic quality score.

Data Hygiene & Analytics

Implementing the code stops the bleeding, but you must prove to Google your traffic is clean.

  1. Link AdMob to Firebase/GA4: Go to AdMob > Settings > Linked Services. This allows AdMob to see user session duration. Bot traffic usually has 0s session duration. GA4 helps Google verify your users are humans.
  2. Cap Frequency: In AdMob Console > Apps > [Your App] > Ad Units > Advanced Settings.
    • Set Frequency Capping to 1 impression per 2 minutes per user.
    • This reduces ad fatigue and accidental double-clicks, signaling a conservative, user-first approach.

Common Pitfalls and Edge Cases

The "App Open" Ad Trap

Do not show "App Open" or Interstitial ads immediately upon application launch. The app is still loading assets, and the UI is often unresponsive. If an ad pops up while a user is trying to tap a UI element that hasn't rendered yet, it triggers high IVT.

  • Fix: Use a generic loading screen. Only trigger the ad request after your main navigation stack has fully mounted.

The "Smart Banner" Deprecation

Google has moved away from "Smart Banners" toward Adaptive Banners. Using the legacy size constants often leads to incorrect height calculations, causing the very layout shifts we are trying to avoid. Always use AnchoredAdaptiveBanner.

Conclusion

Recovering from an "Ad serving is limited" ban requires patience and engineering. It is rarely a mistake by Google; it is usually a strict enforcement of UX guidelines.

By programmatically enforcing test IDs and implementing a layout-stable SafeAdContainer, you protect your users from bad UX and your account from invalid traffic flags. Once these changes are live, traffic assessment typically takes 2 to 4 weeks before the limitation is lifted.