Stop Dialogs From Stacking: One Gate for Paywalls, Review Prompts, and Rewarded Ads
A field record of curing the bug where a paywall, a review prompt, and a rewarded ad all surface at once, fixed with a single priority-based modal gate. I let an Antigravity agent sweep up the scattered show() calls, but kept the display policy in my own hands.
A rewarded ad finished, and the very next instant a review prompt and a paywall both jumped onto the screen. It happened in one of my own wallpaper apps (over 50 million cumulative downloads across iOS and Android) during a staged rollout. To the user it reads as one thing: "I was made to watch an ad, and then double-charged with two stacked requests."
The cause was mundane. The show() calls that present dialogs were scattered all over the codebase, and no single place knew what was currently on screen. The ad callback fired independently of the review-prompt timer, and both decided, correctly in isolation, that now was a fine time to appear. Each rule was right; combined, they were broken.
This article walks through how I cured that collision with a single priority-based coordinator I call ModalGate. The code is Android (Kotlin), but the idea is identical in SwiftUI or React Native. I also cover the line I drew: hand the sweep of scattered calls to an Antigravity agent, and keep the decision of which modal wins for myself.
The root cause is that nobody knows what is already on screen
Apps with colliding modals share a structure: the decision to present is scattered across the callers.
Paywall: the moment a gated feature is touched, PaywallDialog.show()
Review prompt: once launch count crosses a threshold, ReviewInduction.maybeShow()
Rewarded ad: after the back button or a specific action, RewardedIntersDialog.show()
Each only checks its own "am I allowed to appear" condition. The invariant of at most one modal on screen at a time is written nowhere, so the second one stacks on the first without noticing it. Worse, priority is implicit. You want the paywall, which drives revenue, to win, but if the review-prompt timer happens to fire first, a low-value modal eats the display slot of a high-value one.
There is one fix: route every modal through a single coordinator. No one calls show() directly. The gate decides, in one place, whether something is on screen and which request goes next.
A minimal central gate: the single-display invariant and priority
Here is the core ModalGate. It does exactly three things: show one modal at a time, show higher priority first, and present the next one once the current closes.
// Higher weight wins. Put revenue-critical modals on top.enum class ModalPriority(val weight: Int) { PAYWALL(100), // feature gating / purchase funnel REWARDED_AD(60), // rewarded ad REVIEW_PROMPT(20), // store review prompt}// A request to present. `show` defers the actual presentation as a lambda.data class ModalRequest( val id: String, val priority: ModalPriority, val show: (onDismiss: () -> Unit) -> Unit,)object ModalGate { private val pending = mutableListOf<ModalRequest>() private var active: ModalRequest? = null private var canPresent = false // is it safe to present in the foreground? // Callers use only this. Calling show() directly is forbidden. @MainThread fun enqueue(request: ModalRequest) { // guard against double-enqueue of the same kind if (pending.any { it.id == request.id } || active?.id == request.id) return pending.add(request) pump() } @MainThread fun setPresentable(value: Boolean) { canPresent = value if (value) pump() } private fun pump() { if (!canPresent || active != null) return val next = pending.maxByOrNull { it.priority.weight } ?: return pending.remove(next) active = next next.show { onDismissed(next) } } private fun onDismissed(request: ModalRequest) { if (active?.id == request.id) active = null pump() // on to the next one }}
The caller changes like this. Stop hitting show() directly; just enqueue a request.
// Before: each site presented on its ownPaywallDialog(activity).show()// After: via the gate. Whether and when it shows is the gate's call.ModalGate.enqueue( ModalRequest(id = "paywall", priority = ModalPriority.PAYWALL) { onDismiss -> PaywallDialog(activity).apply { setOnDismissListener { onDismiss() } }.show() })
"Two at once" can no longer happen by construction. If the review prompt and the paywall are enqueued in the same instant, the gate presents the higher-weight paywall first and only then decides whether to show the review prompt. The moment priority is explicit in code, the implicit tug-of-war disappears.
✦
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
✦Collapse modal collisions, like a paywall firing right on top of a review prompt, into one priority-ordered gate you can drop into your app today
✦Carry over the exact code for the three places a naive gate breaks: async ad callbacks, Activity lifecycle, and a duplicated back-button ad gate
✦Take home a clear line: hand the mechanical sweep of scattered show() calls to an agent, and keep the display-priority decisions with the human
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.
Pitfall 1: presenting while the Activity is in the background
This was the first one I hit. Even when show() itself runs, trying to present a DialogFragment after the Activity passes onStop either crashes with IllegalStateException or is silently dropped. Ad callbacks return hundreds of milliseconds to a few seconds late, so if the user returns home in that window, this is exactly the gap they land in.
The fix is to give the gate a notion of "is it safe to present right now" and sync it with the lifecycle. That is what the canPresent flag is for.
// register once in BaseActivity, etc.class GateLifecycleObserver : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) = ModalGate.setPresentable(true) override fun onPause(owner: LifecycleOwner) = ModalGate.setPresentable(false)}// in the Activitylifecycle.addObserver(GateLifecycleObserver())
Now requests enqueued while backgrounded pile up in pending, and the instant the app returns to the foreground pump() runs and presents them. The vanishing-modal bug and the exception both stop. Managing the lifecycle and the modals separately was the mistake; presentability is a dependent variable of the lifecycle. I hit a similar recreate-time trap with theme switching, which I wrote up in fixing the white-screen on theme switch with AppRestarter.
Pitfall 2: reserve the ad's slot at request time, not after load
The second trap is harder to see. A rewarded ad has a load delay between request and actual display. If you naively write "enqueue once the ad has loaded," a review prompt can cut in during those few seconds of loading and present first. The user closes the review prompt, and then a long-forgotten ad pops up: the worst possible feel.
The right approach is to reserve the gate slot before you load the ad. Take the slot at request time, and return it if the load fails.
fun requestRewardedAd(activity: Activity) { ModalGate.enqueue( ModalRequest(id = "rewarded", priority = ModalPriority.REWARDED_AD) { onDismiss -> RewardedAd.load(activity, AD_UNIT_ID, object : RewardedAdLoadCallback() { override fun onAdLoaded(ad: RewardedAd) { ad.fullScreenContentCallback = object : FullScreenContentCallback() { override fun onAdDismissedFullScreenContent() = onDismiss() override fun onAdFailedToShowFullScreenContent(e: AdError) = onDismiss() } ad.show(activity) { /* grant reward */ } } // return the slot on load failure too. Forgetting to freezes the gate. override fun onAdFailedToLoad(e: LoadAdError) = onDismiss() }) } )}
The point is to keep the load inside the show lambda and call onDismiss() exactly once on every path. Break that and active is never released and the gate goes silent. I once forgot to call onDismiss() on the load-failure path and no modal would appear ever after. Always design slot reservation and release as a pair.
Pitfall 3: a duplicated back-button ad gate fighting ModalGate
The third one is design duplication. Many apps already have their own gate: "show an ad every N back presses." Run that separately from ModalGate and the ad the back-button logic decides to show collides again with the paywall ModalGate wants to present. The moment you own two gates, you are adding a source of collisions while believing you are preventing them.
The fix is to demote the back-button logic from "present or not" to "enqueue or not." The final present-or-not decision is unified in ModalGate.
private var backPressCount = 0override fun onBackPressed() { backPressCount++ if (backPressCount % SHOW_AD_EVERY == 0 && !adFreeManager.isAdFree) { requestRewardedAd(this) // whether to show is the gate's call return } super.onBackPressed()}
The back button only requests "I would like to show an ad"; it does not arbitrate priority. Now the decision-maker lives in one place. Where the source of truth for state like isAdFree lives is itself a design decision; I wrote about centralizing purchase state in the ad-free source-of-truth pattern.
Hand the sweep to the agent; keep the policy yourself
Here is where Antigravity earned its keep. The bulk of adopting ModalGate was not writing new code; it was finding every direct show() and rewriting it to go through the gate. The wallpaper app has many screens, and show() calls were scattered across dozens of sites. Catching them all by hand is not realistic.
What I handed the agent was only the mechanical, coverage-bound part:
Enumerate every show( call for Dialog, DialogFragment, and ad presentation across all files.
Propose a diff converting each into the ModalGate.enqueue(...) form.
After conversion, write a small check that no direct show() remains.
The third is worth keeping in CI as a regression guard. Here is the simple gate I had it write.
# detect any direct dialog.show() bypassing the gatehits=$(grep -rnE '\.(show)\(\)' app/src/main/java \ | grep -iE 'Dialog|Paywall|Review|Rewarded' \ | grep -v 'ModalGate')if [ -n "$hits" ]; then echo "Direct presentation bypassing the gate remains:"; echo "$hits"; exit 1fiecho "All presentations go through ModalGate"
What I did not hand the agent was which priority each modal gets. Placing the paywall above the review prompt, showing nothing in the first few seconds after launch, capping modals at two per session: these trade revenue against user experience and depend on the app's character. The agent is overwhelmingly faster at "bulk-convert to equivalent code" and "check for completeness." The human decides "what should win." That line maps directly onto how I run apps day to day: build the work itself by hand, and lean on AI for the operations around it.
If you want a per-session frequency cap, you just add a counter to the gate.
private var shownThisSession = 0private const val MAX_PER_SESSION = 2private fun pump() { if (!canPresent || active != null) return if (shownThisSession >= MAX_PER_SESSION) return // stop over-presenting val next = pending.maxByOrNull { it.priority.weight } ?: return pending.remove(next) active = next shownThisSession++ next.show { onDismissed(next) }}
Verify in a staged rollout before widening
After the swap I did not ship to everyone at once. I roll out this kind of change in stages, 5% to 25% to 50% to 100%, checking at each step that crash-free users stays above 99.7% and ANR below 0.20%. Because ModalGate changes presentation timing, I also watched that the review-prompt fire rate and ad impression count did not quietly drop. The outcome: the "two stacked at once" class of reports stayed at zero through every stage as I widened. I will not inflate the numbers. What is certain is the one thing that matters: a structural bug stopped reproducing once the structure changed.
To start, route only your most painful pair, usually the review prompt and the paywall, through the gate first. If putting those two behind the gate makes the collision disappear, you have your justification to sweep up the rest. For surface design in apps with several revenue funnels, the App Clip and Widget monetization-funnel guide is a useful companion.
Thank you for reading. I hope it gives you a foothold if several funnels are colliding in your own app.
Share
Thank You for Reading
Antigravity 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.