RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
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.

AdMob70Crashlytics12Remote Config6interstitialfrequency capwallpaper app23indie developer37

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-05-26
Two Weeks Tightening Up iPad Support for a Rork-Generated Wallpaper App
Notes from spending two weeks tightening up iPad support for a wallpaper app I scaffolded with Rork. Coming from an iPhone-centric indie practice since 2014, I cover where Rork's defaults stopped, how the AdMob adaptive banner misbehaved on iPad, and what changed in retention afterwards.
Dev Tools2026-05-21
Automating Production Incident Response as a Solo Developer — Crashlytics, Sentry, Slack Routing, and Staged Rollback
Twelve years of running my own iPhone and Android apps, accumulating 50 million downloads, taught me a specific shape for production incident response. This article shares the Crashlytics + Sentry double layer, Slack routing into interrupt and log channels, and a Remote Config plus EAS Update staged rollback I keep returning to.
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.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →