◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-28Advanced

Tuning Interstitial Ad Fatigue Across Six Rork iOS Wallpaper Apps with Crashlytics and Remote Config

An implementation note from running six Rork-built iOS wallpaper apps in parallel and rebalancing interstitial fatigue with a three-layer frequency cap powered by Crashlytics non-fatal signals and Remote Config thresholds. Swift code, Cloudflare Workers config, and measured eCPM / D1 retention numbers from a four-week phased rollout.

AdMob73Crashlytics12Remote Config10interstitialfrequency capwallpaper app22indie developer43

✦ Premium Article

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 (UserDefaults plus 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.willResignActive fired during an ad dismiss (user likely left the app mid-tap)
  • MemoryWarning while an ad is presented (often right after a high-resolution wallpaper preview)
  • App force-quit within three seconds of an ad dismiss (willTerminate observed)

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.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A three-layer frequency cap that fuses local session history with Crashlytics non-fatal signals and Remote Config thresholds in under a second
✦Measured numbers from a four-week phased rollout: D1 retention 31.4% → 36.8% and ARPDAU +8.3% while keeping eCPM within −5.4%
✦Cross-app key mapping in Remote Config for six apps and a Swift / Cloudflare Workers kill-switch that rolls back inside three minutes
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-06-12
Building a Developer Debug Menu Into Your Rork App — Verify Ads, Purchases, and Remote Config Before Release
A production-safe developer debug menu for Rork apps — switch environments, force test ads, simulate entitlements, and override Remote Config, with working TypeScript code and the pitfalls I hit running six apps.
⬡ Dev Tools2026-05-19
Rolling Out Firebase App Check in Rork Without Breaking AdMob or Crashlytics
A practical, staged rollout for Firebase App Check in Rork apps that keeps Crashlytics reporting, Realtime Database listeners, and AdMob payouts intact — written from running AdMob-funded indie apps in production.
⬡ Dev Tools2026-10-14
My App Open Ad Only Showed When the App Came Back From Background. The 'Returned to Foreground' Signal Fired Before the Ad Had Loaded
On a cold start, the app open ad in my Android app never showed. The foreground signal came before the load began. Here is how I split the signal from the load and rebuilt it as logic you can test ordering by ordering.