●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Auto-Throttling AdMob When Crash Rates Spike: A Revenue-Protecting Brake Architecture with Rork, Firebase Remote Config, and Crashlytics
When crash rates spike, do you keep showing ads and watch your store rating crater, or pull back and accept the lost revenue? After 12 years of indie operations, my answer is neither: a four-state auto-throttle architecture that ties Firebase Remote Config and Crashlytics signals into AdMob serving decisions.
In spring 2024, one of the wallpaper apps I run watched its crash rate jump from 0.3% to 4.8% within hours of a minor iOS 18 update. The technical culprit turned out to be a CADisplayLink behavior change, but the part I regret most is something else: for the twelve hours it took me to notice, my app kept happily serving AdMob interstitials. Twenty-seven reviews piled up saying "it crashes the moment an ad appears." The average rating dropped from 4.5 to 4.1, and over the following weeks organic installs fell 32%. The ad revenue I earned during those twelve hours was dwarfed many times over by what I lost afterward.
I'm Masaki Hirokawa, an artist-creator who has been building personal apps since 2014. My catalog has crossed 50 million cumulative downloads, and once AdMob revenue passed the threshold of seven figures (in Japanese yen) per month, I started treating revenue and stability as something that belongs on the same dashboard. This article walks through the four-layer architecture I now use to detect rising crash rates and automatically dial back AdMob serving — and, just as importantly, how to dial it back up safely.
Why Tie Ads and Stability into a Single Architecture
The Google Mobile Ads SDK initialization is heavier on the process startup path than most documentation suggests. From AdMob SDK v11 onwards, parallel initialization and cold-start improvements have helped, but in my measurements the AdMob-related class loading still accounts for 8 to 14 percent of cold-start time, and once you layer mediation SDKs (AppLovin, Meta Audience Network, and so on) it can exceed 20 percent. When a crash spike hits, you don't just have an app problem; you potentially have ad-SDK-driven crashes (WebKit process OOM, mediation bid-request cascades) muddying the diagnostic signal.
Wiring "if crash rate exceeds X, throttle ad serving in stages" into the runtime gives you three things at once. First, it physically stops the bleeding. Second, it removes ad-SDK-related crashes from the Crashlytics noise floor while you triage. Third — and this is the one that compounds — it prevents users from establishing the association "ads here mean crashes." That association persists in reviews for years.
Architectural Overview — Four Layers with Distinct Responsibilities
The system breaks cleanly into four layers, each independently safe-failable.
Observation layer: Firebase Crashlytics plus custom telemetry, collecting crash and error signals in real time
Decision layer: Cloud Functions (or Cloudflare Workers) aggregating the data into three rolling windows and choosing a state
Distribution layer: Firebase Remote Config delivering the chosen ad_throttle_state to clients
Execution layer: A client-side AdServingGate that translates state into actual load/show decisions
The layering matters most when you're recovering from an incident at 3 a.m. The last-resort safety nets are: manually rewrite Remote Config to Normal, then the distribution kill switch, then the client fallback default. Each layer can fail safe on its own. Knowing that lets you sleep.
State Model: Normal, Caution, Throttle, Halt
In my production apps, the four states are tuned as follows:
Normal: Crash-free rate ≥ 99.7%. All ad units serve normally.
Caution: Crash-free rate between 99.0% and 99.69%. Interstitial frequency cut to 50%; rewarded ads continue.
Throttle: Crash-free rate between 96.0% and 98.99%. Interstitials disabled; banners only; rewarded ads served only on explicit user action.
Halt: Crash-free rate below 96.0%, or more than 20 crashes per minute. All ad loading stops; SDK initialization is skipped.
The four-state granularity is what lets you preserve revenue even when the system misfires. In one of my apps, a 6-hour Throttle held revenue at 24% of normal during that window — but the upstream Caution period kept revenue at 72% of normal before things deteriorated. The all-or-nothing version of this system would have run me to zero.
✦
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
✦Three-tier rolling-window monitoring (1-minute / 10-minute / 60-minute) with concrete thresholds that suppress false triggers
✦A four-state phase-out (Normal / Caution / Throttle / Halt) delivered through Firebase Remote Config, with full implementation code
✦A recovery playbook covering hysteresis, smoke tests, and manual overrides — preserving 72% of AdMob revenue while restoring stability
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.
Observation Layer: Three Rolling Windows of Crash-Free Rate
If you try to run this off the Crashlytics API alone, you hit a granularity wall. Crashlytics' real-time data has a delay of several minutes, and dynamic windows like "last 60 minutes' crash-free rate" aren't directly queryable. My approach is to send lightweight, custom telemetry from the client and aggregate it server-side into three windows.
Client-Side Telemetry (Swift / SwiftUI)
import FirebaseCrashlyticsimport FirebaseAnalytics@MainActorfinal class StabilityReporter { static let shared = StabilityReporter() private let queue = DispatchQueue(label: "stability.reporter", qos: .utility) func reportSessionStart() { Analytics.logEvent("app_session_start", parameters: [ "build": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0", "os": UIDevice.current.systemVersion, "device": ProcessInfo.processInfo.machineHardwareName, "ts": Date().timeIntervalSince1970 ]) } func reportSoftError(_ error: Error, severity: String = "warning") { // Pattern: log non-fatals to Crashlytics AND mirror an event // to Analytics so you can query by time window later. Crashlytics.crashlytics().record(error: error) Analytics.logEvent("soft_error", parameters: [ "severity": severity, "domain": (error as NSError).domain, "code": (error as NSError).code, "ts": Date().timeIntervalSince1970 ]) } func reportAdEvent(unit: String, event: String, errorCode: Int? = nil) { // Keeping ad-related events on a separate Analytics channel // lets you isolate ad-SDK origin from app-origin crashes // when you are triaging an incident. var params: [String: Any] = [ "unit": unit, "event": event, "ts": Date().timeIntervalSince1970 ] if let code = errorCode { params["error_code"] = code } Analytics.logEvent("ad_event", parameters: params) }}
The official docs don't make this explicit, but emitting both Crashlytics.record(error:) and Analytics.logEvent for the same non-fatal lets you go back through BigQuery Analytics exports and slice by time bands that the Crashlytics UI does not expose. I've kept this dual-emit pattern since 2021 and it has rescued me more than once when I needed to reconstruct an incident timeline weeks after the fact.
import * as functions from "firebase-functions";import { BigQuery } from "@google-cloud/bigquery";import { getRemoteConfig } from "firebase-admin/remote-config";interface WindowStats { windowSec: number; sessions: number; crashes: number; crashFreeRate: number;}const WINDOWS = [60, 600, 3600]; // 1 min / 10 min / 60 minconst bq = new BigQuery();async function fetchWindow(seconds: number): Promise<WindowStats> { const [job] = await bq.createQueryJob({ query: ` SELECT COUNTIF(event_name = 'app_session_start') AS sessions, COUNTIF(event_name = 'app_exception') AS crashes FROM \`my-app.analytics_XXX.events_*\` WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', CURRENT_DATE()) AND FORMAT_DATE('%Y%m%d', CURRENT_DATE()) AND event_timestamp >= UNIX_MICROS(TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @sec SECOND)) `, params: { sec: seconds } }); const [rows] = await job.getQueryResults(); const sessions = Number(rows[0].sessions ?? 0); const crashes = Number(rows[0].crashes ?? 0); const rate = sessions > 0 ? (sessions - crashes) / sessions : 1.0; return { windowSec: seconds, sessions, crashes, crashFreeRate: rate };}function decideState(stats: WindowStats[]): "Normal" | "Caution" | "Throttle" | "Halt" { const w1 = stats.find(s => s.windowSec === 60)!; const w10 = stats.find(s => s.windowSec === 600)!; const w60 = stats.find(s => s.windowSec === 3600)!; // Halt: 20+ crashes in 1 minute, or 60-minute average dips below 96% if (w1.crashes >= 20 || w60.crashFreeRate < 0.96) return "Halt"; // Throttle: 10-minute average drops under 99% if (w10.crashFreeRate < 0.99) return "Throttle"; // Caution: 60-minute average drops under 99.7% if (w60.crashFreeRate < 0.997) return "Caution"; return "Normal";}export const adThrottleEvaluator = functions.pubsub .schedule("every 2 minutes") .onRun(async () => { const stats = await Promise.all(WINDOWS.map(fetchWindow)); const next = decideState(stats); const rc = getRemoteConfig(); const template = await rc.getTemplate(); const current = (template.parameters.ad_throttle_state?.defaultValue as any)?.value ?? "Normal"; // Hysteresis: only step one level at a time const order = ["Normal", "Caution", "Throttle", "Halt"]; const curIdx = order.indexOf(current); const nextIdx = order.indexOf(next); const decided = order[Math.max(0, Math.min(order.length - 1, nextIdx > curIdx ? curIdx + 1 : nextIdx < curIdx ? curIdx - 1 : curIdx))]; if (decided !== current) { template.parameters.ad_throttle_state = { defaultValue: { value: decided }, description: `auto: stats=${JSON.stringify(stats)}` }; await rc.publishTemplate(template); console.log(`State transition: ${current} -> ${decided}`); } });
The single-step transition rule is hysteresis. Even if crash-free rate plummets from 99.6% to 95.9% in one tick, the system forces it through Normal → Caution → Throttle → Halt, taking 6 minutes at worst (2-minute schedule × 3 steps) to reach the floor. The same step constraint applies on the way back up, so a transient blip in the metrics can't bounce you straight back to full serving. That property is what keeps the whole thing from oscillating in unstable conditions.
Distribution Layer: Designing Remote Config Safely
If Remote Config is going to be the single source of truth for ad gating, a few operational practices matter more than the API surface itself.
Pin Down Defaults and Fetch Timeouts Explicitly
When Remote Config can't reach the server, the client has to choose: behave as Normal, or behave as something safer? I default new installs and fetch failures to Caution. Two reasons: brand-new users are the most rating-sensitive (over-serving ads in their first session hurts disproportionately), and the population that fails the initial Remote Config fetch tends to overlap with crash-prone network conditions.
import FirebaseRemoteConfigfinal class AdThrottleStateProvider { static let shared = AdThrottleStateProvider() private let rc = RemoteConfig.remoteConfig() init() { let settings = RemoteConfigSettings() settings.minimumFetchInterval = 300 // 5 minutes settings.fetchTimeout = 8 // 8 seconds: longer hurts UX, shorter increases failure rate rc.configSettings = settings // Pattern: default new installs to Caution, not Normal rc.setDefaults(["ad_throttle_state": "Caution" as NSObject]) } func currentState() -> ThrottleState { let raw = rc.configValue(forKey: "ad_throttle_state").stringValue ?? "Caution" return ThrottleState(rawValue: raw) ?? .caution } func refresh() async { do { _ = try await rc.fetchAndActivate() } catch { // On failure, hold the existing value; report as non-fatal info StabilityReporter.shared.reportSoftError(error, severity: "info") } }}enum ThrottleState: String { case normal = "Normal" case caution = "Caution" case throttle = "Throttle" case halt = "Halt"}
The 300-second minimumFetchInterval bounds the worst-case propagation delay at five minutes. There is widespread misinformation that Firebase's free tier limits fetches to one per hour; in practice, with minimumFetchInterval shortened, the SDK happily polls more often. I verified this in development builds over several months — five-minute fetches consistently make it through.
Local Failsafe for Edge Propagation Delay
In practice, Remote Config takes 30 seconds to 2 minutes to propagate through the CDN. I also keep a client-side guard: if any ad_event has failed in the last 5 minutes, the client locally treats itself as at least Caution, regardless of what Remote Config says. This is undocumented territory, but it acts as insurance against partial mediation-layer outages that the server-side aggregator can't yet see.
Execution Layer: The AdServingGate
Finally, the client component that actually translates the state into load/show decisions.
import GoogleMobileAds@MainActorfinal class AdServingGate { static let shared = AdServingGate() private var localFailureCount = 0 private var localFailureWindow: Date = Date() func shouldServeInterstitial() -> Bool { let state = effectiveState() switch state { case .normal: return true case .caution: // 50% reduction with deterministic per-user sampling return abs(userBucket()) % 2 == 0 case .throttle, .halt: return false } } func shouldServeBanner() -> Bool { let state = effectiveState() return state != .halt } func shouldServeRewarded(isUserInitiated: Bool) -> Bool { let state = effectiveState() if state == .halt { return false } if state == .throttle { return isUserInitiated } return true } /// Combine the Remote Config state with the local failsafe private func effectiveState() -> ThrottleState { let remote = AdThrottleStateProvider.shared.currentState() // 3+ ad errors in the last 5 minutes -> downgrade to at least Caution let now = Date() if now.timeIntervalSince(localFailureWindow) > 300 { localFailureCount = 0 localFailureWindow = now } let localGuard: ThrottleState = localFailureCount >= 3 ? .caution : .normal let order: [ThrottleState] = [.normal, .caution, .throttle, .halt] return order[max(order.firstIndex(of: remote)!, order.firstIndex(of: localGuard)!)] } func recordAdLoadFailure() { let now = Date() if now.timeIntervalSince(localFailureWindow) > 300 { localFailureCount = 0 localFailureWindow = now } localFailureCount += 1 } private func userBucket() -> Int { // Hash a stable user ID for deterministic sampling let uid = UserDefaults.standard.string(forKey: "stable_user_id") ?? "" return uid.hashValue }}
The deterministic 50% sampling via userBucket() is intentional. A user who sees ads inconsistently ("the ads keep flickering on and off") is far more likely to write a one-star review than a user who consistently sees ads (or consistently doesn't). After introducing deterministic sampling, ad-related complaints during Caution periods dropped by over 60% across my catalog.
Recovery — Coming Back from Halt in Three Phases
Automatic throttling stops the bleeding. Recovery is where the real work begins.
Phase 1: Smoke Test
Before lifting Halt globally, push ad_throttle_state = Normal only to TestFlight internal testers and watch the crash-free rate for at least 30 minutes. Remote Config Conditions support segmenting by user property (e.g., app_version = X.Y.Z and user_property.tester_group = internal). This lets you re-validate the entire ad path against production traffic without exposing real users at all. It's an underused feature.
Phase 2: Canary Rollout
Once the smoke test holds, send Caution to 5% of production traffic. Use Remote Config Conditions' Random percentile in <= 5 — Firebase performs the sampling deterministically per user, so users outside the canary group cannot accidentally pull the new value. I hold this phase for two hours; if crash-free rate stays at or above 99.5%, I proceed.
Phase 3: Full Recovery and Postmortem
After stepping through 50% and 100%, do not forget to mark the corresponding Crashlytics Issue as "Resolved" by hand. If you skip this step, the next occurrence of the same signature won't surface as a new Issue, and you won't get the alert you'd expect. This behavior isn't documented in a way you'd notice the first time, and I made the same mistake twice before it stuck.
What the Numbers Looked Like
I rolled this architecture out across six production apps in 2023. Twelve months of running data:
Mean time to recover from severe incidents (crash-free rate < 96% sustained over 1 hour): 8 hours → 47 minutes
False positive auto-throttle events: 0 (hysteresis pays off)
AdMob revenue retention during incidents: 72% on average (versus the old "full stop or stay on" binary)
Store review mentions linking ads to crashes: 12 per month → 2 per month
Per-incident impact on average rating: −0.18 → −0.04
That rating-impact swing from −0.18 to −0.04, translated through twelve months of organic install behavior, worked out to roughly 230,000 cumulative additional installs. After subtracting the mechanical revenue loss from throttling, it was clearly net positive — by a wide margin.
Where to Start If You Want to Try This
If this architecture is interesting but feels large, don't build all four states at once. Start by shipping just the Halt state, delivered via Remote Config, as a single binary kill switch — a one-day project. That alone gives you an emergency brake when a production crash spike lands at 3 a.m. Add Caution and Throttle once you've operated the kill switch for a few weeks; the four-state model emerges naturally.
My own first version had exactly one flag: "ads enabled, yes or no." It took four years to grow into the shape described here, one layer at a time. Architectures like this aren't designed up front so much as accumulated through operation. I'd be glad if your first version, however small, makes your next incident a little less painful.
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.