“Multiple consecutive ads”: designing ad frequency that survives App Review
The rejection was two sentences long:
“There are issues with the app's user interface that contribute to a lower-quality user experience than App Store users expect. Specifically, the app displays excessive ads in a manner that negatively impacts the user's experience and displays multiple consecutive ads.”
No screenshot, no timestamp, no reproduction steps. Just a claim that was, unfortunately, true.
The shape of the bug
The game — Byte-Sized Tycoon, an idle tycoon with four prestige paths — had four places where a run could end: a hard reset, a prestige reset, a second prestige path, and a “breakthrough” confirmation. Each one did this:
const interstitialAd = interstitialAdRef.current;
if (interstitialAd && interstitialAdLoaded) {
interstitialAd.show();
}
Four independent call sites, no shared state, and each one individually correct. The problem is what happens when two of them fire close together: prestige, then breakthrough a few seconds later, and the player gets two full-screen ads back to back. Add a rewarded video to the mix and you can produce three.
This is the classic multi-call-site bug. Nothing is broken; the system as a whole has no opinion about frequency.
Put the policy in one place
Ads should not be triggered by screens. They should be requested from a policy object that owns the decision, with a clock you can inject so the rules are testable:
export interface AdPolicyState {
sessionStartedMs: number;
lastInterstitialMs: number | null;
lastRewardedMs: number | null;
interstitialsShown: number;
}
// no ads at launch
export const SESSION_GRACE_MS = 90_000;
// at most one interstitial every 3 minutes
export const MIN_INTERSTITIAL_INTERVAL_MS = 180_000;
// never right after a rewarded video
export const MIN_GAP_AFTER_REWARDED_MS = 120_000;
export const MAX_INTERSTITIALS_PER_SESSION = 3;
export function canShowInterstitial(state: AdPolicyState, now: number) {
if (state.interstitialsShown >= MAX_INTERSTITIALS_PER_SESSION) {
return { allowed: false, reason: 'session-cap' as const };
}
if (now - state.sessionStartedMs < SESSION_GRACE_MS) {
return { allowed: false, reason: 'session-grace' as const };
}
if (state.lastInterstitialMs !== null
&& now - state.lastInterstitialMs < MIN_INTERSTITIAL_INTERVAL_MS) {
return { allowed: false, reason: 'too-soon' as const };
}
if (state.lastRewardedMs !== null
&& now - state.lastRewardedMs < MIN_GAP_AFTER_REWARDED_MS) {
return { allowed: false, reason: 'after-rewarded' as const };
}
return { allowed: true };
}
Two rules beyond frequency matter:
- A grace period. Ninety seconds after launch with no ads at all. Nobody installing your app should meet a full-screen ad before they meet the product.
- No ad on the first reset. In our case the first prestige is a milestone. Charging an ad for it is how you turn a reward into a toll.
Make the call sites unable to bypass it
Replace every direct show() with a single function, and keep the loaded flag in a ref
so stale closures cannot lie to you:
const showInterstitialNow = useCallback(() => {
const ad = interstitialAdRef.current;
if (!ad || !interstitialLoadedRef.current) return false;
const now = Date.now();
if (!canShowInterstitial(adPolicyRef.current, now).allowed) return false;
adPolicyRef.current = recordInterstitial(adPolicyRef.current, now);
ad.show();
return true;
}, []);
And record rewarded views, because “the player just watched a video on purpose” is the strongest possible reason not to interrupt them again:
ad.addAdEventListener(RewardedAdEventType.EARNED_REWARD, () => {
adPolicyRef.current = recordRewarded(adPolicyRef.current, Date.now());
grantReward();
});
Test the exact thing that got rejected
The whole point of a pure policy module is that the rejection case becomes a unit test:
test('two resets seconds apart cannot produce two ads', () => {
let s = createAdPolicy(T0);
const firstReset = T0 + SESSION_GRACE_MS;
assert.equal(canShowInterstitial(s, firstReset).allowed, true);
s = recordInterstitial(s, firstReset);
assert.equal(canShowInterstitial(s, firstReset + 4_000).allowed, false);
});
Six tests cover the rules: grace, spacing, post-reward gap, session cap, immutability, and the consecutive-reset case. They run in milliseconds and they encode a promise you made to a reviewer.
Replying to App Review
Vague rejections invite vague replies. Be specific, describe behaviour rather than intentions, and name the build:
Build 9 adds a single ad-frequency policy that every interstitial passes through: no interstitial during the first 90 seconds of a session; at most one every 3 minutes; none within 2 minutes of a rewarded ad the player chose to watch; at most 3 per session; and none on the player's first reset. Interstitials now only appear at reset/prestige moments — never during gameplay, on launch, or over another ad.
What we would do differently
Frequency policy should exist on day one, not after a rejection. It is a twenty-line module and a handful of tests, and it is the difference between “the app shows ads” and “the app shows too many”. If a game cannot justify interstitials under those rules, that is a revenue problem to solve with rewarded video — not a reason to remove the rules.
Get new posts
One email when a new post ships. No spam.