Tuning AdMob in 17 Flutter Apps From Our Own Server
Why we replaced Firebase Remote Config with a 141-line PHP endpoint, the app-side fallback, and what changing ad intervals without a rebuild did to CTR.
Ad settings in mobile apps are usually constants. Interval between interstitials, how many per session, how long the app-open ad waits: they live in Dart code, and every change means a build, a store review and days of waiting for users to update. With 17 Flutter apps, that turns into never changing them at all.
We moved those numbers onto our own server. This post covers the contract, the client-side fallback that keeps it safe, the guardrails on the admin side, and what happened when we actually used it.
Why our own endpoint instead of Remote Config
Firebase Remote Config solves "change values without a release". We replaced it anyway because the same request could do two more jobs:
- Traffic by hour and weekday. The AdMob Reporting API has no hour dimension. Every app already calls the config endpoint once per launch, so counting those calls gives a 7×24 launch heatmap per app, with no analytics SDK.
- Policy checks before saving. A value that would put an app into a risky ad pattern should be impossible to save, not just discouraged.
firebase_remote_config was removed from the apps after the switch.
The contract
One unauthenticated GET per app launch:
GET /tlabo-ad/api/config.php?app=takarakuji&tz=540
| Parameter | Handling |
|---|---|
app |
lowercased, stripped to [a-z0-9_]; empty → 400 {"error":"app parameter required"} |
tz |
device UTC offset in minutes, clamped to −720…840. Missing → bucketed in UTC and counted separately as no_tz_hits |
push_id, ping=1 |
used by push-open tracking; ping=1 skips the traffic counter so an open is not counted as a launch |
The response is always the same flat object of integers:
{
"ad_interstitial_max_per_session": 3,
"ad_interstitial_interval_sec": 240,
"ad_interstitial_first_delay_sec": 60,
"ad_app_open_cooldown_min": 2,
"ad_app_open_gap_sec": 2,
"ad_inactivity_trigger_min": 3,
"ad_new_user_grace_days": 3,
"ad_interstitial_tap_delay_ms": 700
}
Headers: Cache-Control: no-store, so no CDN or proxy ever serves a stale policy.
Three rules keep old app versions safe:
- The shape never changes. New keys are added; old keys are never removed or retyped. An app built in August ignores keys added in November.
- Every failure returns defaults. Unknown app, deactivated app, or a database exception: the response is the code defaults with status 200, never an error body the app has to interpret.
- Side effects cannot break the response. The traffic upsert and the push-open counter are wrapped in try/catch. If the stats insert fails, the config is still returned.
Values are merged in a fixed order: code defaults, then the app's saved values, then a time-of-day preset if scheduling is enabled for that app. Schedule rules carry a weekday mask and an hour range that may cross midnight; the first matching rule by priority wins.
The client: never wait, never throw
The Flutter side has three layers of values: defaults in code, the last values cached in SharedPreferences, and the server.
Future<void> init() async {
await _loadCache(); // fast, local
unawaited(_refreshFromServer()); // 5 s timeout, errors swallowed
}
int get interstitialIntervalSec =>
_values['ad_interstitial_interval_sec'] ?? _defaults['ad_interstitial_interval_sec']!;
Two decisions matter more than the code:
- New values apply from the next launch. The fetched values are written to the cache, not swapped in live. Ad behaviour never changes in the middle of a session, and the splash screen no longer waits up to five seconds on a slow network for a policy it does not need yet.
- Zero is a valid value. An early version accepted values only if
v > 0, which made it impossible to turn a feature off remotely. It was changed tov >= 0.
One more bug the remote setup exposed: the app-open cooldown was kept in memory. When the OS killed the app in the background, the cooldown reset, and the next resume showed an ad the user did not expect. The timestamp now lives in SharedPreferences.
Rolling it out without changing anything
Each app had its own hard-coded numbers: 20 minutes of cooldown in one, 240 minutes and 1800 seconds in another. Before the first app switched to remote values, the server was loaded with that app's old constants. The first release therefore changed only where the numbers came from, not the behaviour. Tuning came later, one deliberate change at a time.
The allowed ranges are clamped on the server. Upper limits were raised three times on the first day, because real apps already used values above our guesses (an 1800-second interval, a 360-minute cooldown, a 120-second gap). Lower limits sit above our own policy thresholds. AdMob publishes no numeric limits, so ours are conservative house rules: for example, an interstitial interval under 60 seconds is rejected, and under 120 seconds raises a caution.
Admin guardrails
- Every write is checked against policy before commit. A violation rolls back and returns 409 unless an explicit
override_policyflag is sent. - Every change is logged with before and after snapshots and can be reverted in one click.
- Turning on a time-of-day schedule is refused when the app has fewer than 7 days or 1,000 launches of traffic data, because a heatmap built on a weekend of data is noise.
- Deleting an app requires typing its ID.
What changing values remotely actually did
All of these were server-side changes, with no app rebuild:
| Change | Result |
|---|---|
| Click-timing fixes shipped in an app update, measured two weeks later | App-open CTR fell in all four app/platform pairs checked: 23.14% → 17.29%, 17.39% → 14.98% (p = 0.0074), 7.07% → 4.06% (p = 0.0098), 5.82% → 4.55% (p = 0.0034). Fewer accidental taps. Interstitial CTR did not improve |
| Tap delay before interstitial close: 0 / 700 / 2500 ms | No measurable effect; reverted to 700 ms |
| Interval 240 → 900 s and per-session cap 3 → 2 on 8 apps | Applied the same day, no release. Interstitials were about 11% of revenue, so the expected loss was small and accepted |
| One app: interval 900 → 1800 s, then first-interstitial delay 60 → 300 s | Interstitial impressions per period 94 → 66 → 49; CTR 57.4% → 53.0% → 28.6%. The first-delay change did most of the work |
The last row has a limit: 300 seconds is the top of the allowed range, so that lever is now used up for that app. The next improvement there needs an app change, not a server value.
When this is worth building
| Situation | Choose |
|---|---|
| One or two apps, values rarely change | Constants in code are fine |
| You only need remote values | Firebase Remote Config |
| Many apps, frequent tuning, you want launch timing data and policy checks | A small endpoint like this |
The endpoint itself is 141 lines of PHP; the admin screens and API add about 1,100 more. Most of the value came from the boring rules: the response shape never changes, failures return defaults, and values are cached and applied on the next launch.
FAQ
Why not just use Firebase Remote Config?
It works, but we wanted two more things from the same request: an hour-by-weekday traffic heatmap (the AdMob Reporting API has no hour dimension) and server-side policy checks before a value can be saved. A small endpoint on our own server gave both, and firebase_remote_config was removed from the apps.
What happens when the config server is down?
Nothing visible. The app starts with cached values (or code defaults), fetches in the background with a 5-second timeout, never throws, and applies new values from the next launch. Server errors return the defaults with the same JSON shape.
Did changing ad settings remotely actually change results?
Yes, measurably. In one app, two server-side steps (a longer interval, then a longer first delay) took interstitial impressions per period from 94 to 66 to 49 and CTR from 57.4% to 53.0% to 28.6%. A tap-delay experiment (0, 700, 2500 ms) showed no effect and was reverted.