I have been shipping iOS apps as an indie developer since 2014, and across roughly 50 million cumulative downloads at Dolice, AdMob interstitials have been the single biggest revenue line. Running six wallpaper apps in parallel, however, exposed a pattern I did not expect: D1 retention kept sinking from 38% to 31% in cycles, and the cause traced back to interstitial fatigue arriving earlier than the official guidance assumed.
The "show after 30 seconds, cap at two per session" rule from Google's docs creates a fatigue curve that bites heavy users first. A static frequency cap could not keep up. I needed a system that watched each user's recent behaviour and irritation signals, then adjusted the display cap dynamically. The architecture below is the three-layer frequency cap that now runs across six Rork-built iOS apps, with the Swift and Cloudflare Workers code, measured numbers, and the phased rollout plan.
What I was seeing before the change
Numbers from the first two weeks of measurement, averaged across four wallpaper apps (iOS 17.5 to 18.4, combined MAU 23k):
- Average interstitial eCPM: $9.6 → $11.2 (before phased rollout)
- D1 retention: 38.4% → 31.4% (a 7pt drop in four weeks)
- D7 retention: 17.1% → 12.3%
- Crashlytics non-fatal "app unresponsive during ad dismiss": 47/week → 312/week
- One-star review share: 2.1% → 4.7% of all reviews
The eCPM was climbing because of bidding optimisation, but retention, non-fatals, and one-star reviews were moving in lock-step the wrong way. My hypothesis was simple: heavy users were seeing too many ads in a single day. AdMob Reporting only exposes time-bucketed impression counts, so I built a per-user observation of "recent N hours of impressions × forced-exit rate" on the client side.
The three-layer architecture
A shared AdManager.swift module across the six Rork apps decides whether to present an interstitial through three layers:
- Layer 1: Local session history (
UserDefaultsplus in-memory cache) - Layer 2: Crashlytics non-fatal events used as an "annoyance signal"
- Layer 3: Dynamic threshold fetched from Cloudflare Workers wrapping Remote Config
AdMob SDK itself has a frequency cap, but it only supports fixed per-session or per-day limits and cannot follow the fatigue curve of an individual heavy user. The pattern below decides on the client side first, and only when the verdict is "presentable" does it call AdMob's load → present.
Layer 1: Local session history cache
The store keeps the last 24 hours of interstitial impressions, dismiss latency, and active seconds in UserDefaults. Old records are trimmed on append so the blob never grows unbounded.
import Foundation
struct AdImpression: Codable {
let timestamp: TimeInterval
let activeSeconds: Int
let dismissDelayMs: Int
}
final class AdHistoryStore {
static let shared = AdHistoryStore()
private let key = "ad.history.v1"
private let queue = DispatchQueue(label: "ad.history.q")
func record(activeSeconds: Int, dismissDelayMs: Int) {
queue.async {
var all = self.load()
all.append(AdImpression(
timestamp: Date().timeIntervalSince1970,
activeSeconds: activeSeconds,
dismissDelayMs: dismissDelayMs
))
let cutoff = Date().timeIntervalSince1970 - 24 * 3600
all = all.filter { $0.timestamp >= cutoff }
if let data = try? JSONEncoder().encode(all) {
UserDefaults.standard.set(data, forKey: self.key)
}
}
}
func load() -> [AdImpression] {
guard let data = UserDefaults.standard.data(forKey: key),
let arr = try? JSONDecoder().decode([AdImpression].self, from: data)
else { return [] }
return arr
}
func recentCount(within seconds: TimeInterval) -> Int {
let cutoff = Date().timeIntervalSince1970 - seconds
return load().filter { $0.timestamp >= cutoff }.count
}
}The often-overlooked field is dismissDelayMs. Users whose dismiss latency keeps shrinking have started treating interstitials as a chore to clear quickly, and that is an early fatigue signal worth catching.
Layer 2: Crashlytics non-fatal as an annoyance signal
This is the layer not covered by official documentation, and it has been the highest-impact change in production. I record the following events with Crashlytics.crashlytics().record(error:) and aggregate them server-side as a "stress index":
UIApplication.willResignActivefired during an ad dismiss (user likely left the app mid-tap)MemoryWarningwhile an ad is presented (often right after a high-resolution wallpaper preview)- App force-quit within three seconds of an ad dismiss (
willTerminateobserved)
Combining Custom Keys and Custom Logs, I tag users with ad.fatigue.signal=1 and can segment them in the Crashlytics console. Roughly 5,000 users fall into this segment in any given month, and most of them churn just before D7.
Layer 3: Dynamic thresholds via Remote Config
Hardcoded thresholds like "at most three per hour" are hard to optimise across six apps, so they live in Remote Config. A thin Cloudflare Workers wrapper returns thresholds keyed by country, app, and recent non-fatal count.
// cloudflare-worker: /api/ad/freq-cap
export default {
async fetch(req: Request, env: Env) {
const url = new URL(req.url);
const country = req.headers.get("CF-IPCountry") ?? "JP";
const app = url.searchParams.get("app") ?? "wallpaper-a";
const nonFatal = Number(url.searchParams.get("nf") ?? 0);
// Remote Config base values are mirrored into KV
const baseRaw = await env.AD_CONFIG_KV.get(`base:${app}:${country}`);
const base = baseRaw ? JSON.parse(baseRaw) : { capPerHour: 3, capPerDay: 8 };
let { capPerHour, capPerDay } = base;
if (nonFatal >= 2) {
capPerHour = Math.max(1, Math.floor(capPerHour * 0.5));
capPerDay = Math.max(2, Math.floor(capPerDay * 0.6));
} else if (nonFatal === 1) {
capPerHour = Math.max(1, capPerHour - 1);
}
return Response.json({ capPerHour, capPerDay, ttlSec: 600 });
},
};I mirror Remote Config into KV because AdMob-side Remote Config has had two outages in my history, and I wanted independent delivery. The response intentionally stays minimal — just capPerHour and capPerDay. I do not pipe in any ML score; the operational complexity is not worth it against ARPDAU gains.