The Review Prompt Fired but Nothing Appeared — Designing Around Play In-App Review's Quota and No-Show Guarantee
Play In-App Review's launchReviewFlow can succeed without ever showing a dialog. This walks through the three traps — quota, no display guarantee, and silent testing — and the engagement-based trigger design that fires at the right moment without colliding with ads, with steps to have Antigravity implement it.
I still remember the day I wired Play's In-App Review into one of my calming wallpaper apps. I called launchReviewFlow, the addOnCompleteListener came back successful, the log printed "review flow finished" — and yet the star-rating dialog never appeared on screen. Not once.
I swapped test devices, swapped accounts, and tried dozens of times with the same result. The code looked like it was working, but the one thing I cared about stayed silent. As an indie developer who has been shipping apps for years, even I spent the better part of a day convinced my own implementation was broken, hunting for the bug.
The short version: what was broken wasn't the implementation, it was my assumption. Play In-App Review is not a "call it and it shows" API. Google decides whether to display it, and that decision is invisible to you. This article is the trigger design that only becomes correct once you put "no display guarantee" at the center, written up alongside the implementation code.
The contract: a successful callback is not a display guarantee
The first mental shift is that completion of launchReviewFlow does not mean "the dialog was shown." By design, there is no API that tells your app whether the review flow was actually displayed. The completion listener reports only that the flow finished — it cannot distinguish between shown, suppressed by quota, or already reviewed.
// ❌ The common misread — assuming "completed" means "shown"manager.launchReviewFlow(activity, reviewInfo) .addOnCompleteListener { task -> // Even with task.isSuccessful == true, the dialog may never have appeared // Do NOT set a "reviewed" flag here markReviewedAndNeverAskAgain() // ← this is what breeds the silence }
Half of the day I burned came from exactly this. Because I was persisting a "don't ask again" flag in the completion callback, a single run of the flow put the app into a state where it would never fire again — a trap I had built myself. The correct move is to do nothing meaningful in the callback (or, at most, advance an internal cooldown). The starting point is to stop trying to determine, from your side, whether a review happened.
What you want to know
Does the API return it?
Design to adopt
Was the dialog shown?
No
Don't make UX depend on display
Did the user leave a rating?
No
No post-rating rewards or branching
Was it suppressed by quota?
No (returns success)
Cooldown, ready to re-fire later
Did the flow finish?
Yes
Use only to resume navigation
Quota: the invisible wall
The next wall is quota. In-App Review caps how often the review prompt can be offered to the same user. Call launchReviewFlow repeatedly in a short window and the excess calls are quietly ignored — no dialog. And as above, that suppression is invisible from the callback. It comes back as success.
This is genuinely nasty for an indie developer. During testing you launch the app over and over and keep satisfying your review conditions. The result: your own development account hits the quota first, producing exactly the initial symptom — "I implemented it and it won't show."
The design implication is clear: throttle firing deliberately on your side rather than leaning on the API's limit. In my calming apps, the review prompt only fires when "enough time has passed across sessions" and "a satisfaction signal is present." The API quota is just the last safety net; ideally your own trigger policy has already narrowed things down well before it.
✦
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
✦Understand the real contract — a successful launchReviewFlow does not mean a dialog was shown — and build code that never makes UX depend on that callback
✦Ship a review prompt that actually works in production by avoiding the three traps: quota, ReviewInfo lifetime, and silent no-ops in test builds
✦Port an engagement-based trigger that fires only at the peak of satisfaction and yields to ads and paywalls instead of stacking requests
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.
Keep requestReviewFlow and launchReviewFlow separate
The implementation is most stable when you "warm up" the ReviewInfo ahead of time. Do the network-bound preparation with requestReviewFlow to obtain a ReviewInfo, then call launchReviewFlow the instant the moment of satisfaction arrives. That way you never make the user wait at the critical moment.
But ReviewInfo has a lifetime and goes stale if you sit on it too long. Hold one for minutes and it may fail when you finally use it. So the realistic rule is "warm up as you enter the screen, and use it up within that screen."
import android.app.Activityimport android.content.Contextimport com.google.android.play.core.review.ReviewInfoimport com.google.android.play.core.review.ReviewManagerimport com.google.android.play.core.review.ReviewManagerFactoryclass InAppReviewLauncher(context: Context) { private val manager: ReviewManager = ReviewManagerFactory.create(context) private var warmInfo: ReviewInfo? = null /** Call early when entering a screen where satisfaction is likely */ fun warmUp() { manager.requestReviewFlow() .addOnCompleteListener { task -> warmInfo = if (task.isSuccessful) task.result else null // Never crash on failure. Warm up again next time } } /** * Call at the moment you actually want to offer it. * It deliberately does NOT return whether it was shown — * callers must not depend on the result either. */ fun launchIfReady(activity: Activity, onFlowFinished: () -> Unit) { val info = warmInfo if (info == null) { onFlowFinished() // Not warm yet — do nothing, don't block UX return } warmInfo = null // Single-use. Never reuse a ReviewInfo manager.launchReviewFlow(activity, info) .addOnCompleteListener { // Display or not, this hook only resumes the next transition onFlowFinished() } }}
The key is that launchIfReady returns no success value. Hand the caller a "was it shown" result and someone will always write a branch on it, stacking a fragile assumption on top of an API with no display guarantee. When it can't show, still call onFlowFinished() so the app's flow alone keeps moving.
When to fire: catching the peak of satisfaction via engagement
Whether In-App Review works comes down to when you call it, more than the code. Google itself recommends showing it right after the user has felt value. Conversely, firing right after launch, right after a purchase, or right after an error — moments where the ask is transparently self-serving — invites low ratings or gets ignored while still burning quota.
In my wallpaper and calming apps, I define the satisfaction signal like this: saved a fair number of favorites, spent a calm stretch of time inside the app, returned across multiple sessions. Only when these "the user is choosing to come back" conditions stack up does the prompt become a candidate.
/** A pure policy that only decides eligibility. No side effects */class ReviewTriggerPolicy( private val store: ReviewPrefs, private val clock: () -> Long = { System.currentTimeMillis() }) { companion object { private const val MIN_DAYS_BETWEEN = 60L * 24 * 60 * 60 * 1000 // 60 days private const val MIN_FAVORITES = 8 private const val MIN_CALM_SECONDS = 90 } fun shouldOfferReview(signals: EngagementSignals): Boolean { val now = clock() val sinceLast = now - store.lastOfferedAt if (sinceLast < MIN_DAYS_BETWEEN) return false // your own cooldown if (signals.favoritesCount < MIN_FAVORITES) return false // satisfaction signal if (signals.calmSessionSeconds < MIN_CALM_SECONDS) return false if (signals.lastActionWasError) return false // avoid post-frustration return true } /** Record only when you actually fire (regardless of whether it showed) */ fun markOffered() { store.lastOfferedAt = clock() }}
Call markOffered() at the moment you actually attempt launchReviewFlow. Again, "was it shown" is irrelevant. Making the attempt the start of the cooldown prevents the accident where a quota-suppressed no-op fires again immediately and wears the user down. The 60-day interval is where my apps settled; treat it as a value to tune to your app's usage frequency.
Don't collide with ads and paywalls
"Fire at the peak of satisfaction" inevitably clashes with another design, because that same "good moment" is when you also want to grant a rewarded ad, show an interstitial, or surface a membership prompt. Stack those back to back and, from the user's side, it's just a barrage of asks.
I resolve this by forbidding each dialog from showing on its own and centralizing the right to fire in a single coordinator. The review prompt is no exception: it declares "I'd like to show" to the coordinator and is granted only when nothing else is showing in that frame. The idea itself is detailed in centralizing modal display through a coordinator.
// Hand it to the coordinator as a "review" candidatefun maybeOfferReview(activity: Activity, signals: EngagementSignals) { if (!policy.shouldOfferReview(signals)) return modalCoordinator.request(ModalRequest.Review(priority = 20) { policy.markOffered() reviewLauncher.launchIfReady(activity) { modalCoordinator.onModalFinished() } })}
The trick is to give it a priority and arbitrate it alongside the purchase prompt and the back-press ad gate. When priorities clash with the back-button interstitial control, you can align them with the same approach as building the back-press ad gate with flat guards instead of nesting. Accept that a review prompt is not worth interrupting for — yielding when it collides protects your rating in the long run.
So you don't mistake it for "broken" in testing
The final trap is silence in test environments. In-App Review does not show a real dialog for local debug builds or for apps not yet installed from Play. launchReviewFlow returns success, yet nothing appears — which is exactly where I first got stuck.
To verify actual display, you need a build installed via Play's Internal testing or Internal app sharing. Even then quota still applies, so read the outcome not as a binary "shows / doesn't" but as "given the conditions are met, it may or may not show."
Build / distribution path
launchReviewFlow result
Dialog shown
Local debug build
Returns success
No (by design)
Sideload (not via Play)
Returns success
No
Internal testing / app sharing
Returns success
Conditionally yes
Production (quota exceeded)
Returns success
No (silent)
Just pinning this table into your team's docs quietly eliminates most "I implemented it but it won't show" questions. I handed this list to Antigravity and had it expand the rows into a verification procedure matched to my app's BuildConfig and release flow. Give the agent the constraint up front — "In-App Review has no display guarantee, is silent in debug, and is only verifiable via internal testing" — and its generated test steps get sharp immediately.
The policy line: pre-filtering vs. allowed design
One thing to confirm whenever you touch In-App Review is the boundary: you must not gate the prompt on satisfaction. Asking "Do you like this app?" first, routing only the "yes" users to the review flow and the "no" users to a contact form — that "funnel only the happy path" pre-filter is prohibited by Google's policy.
Selecting timing by behavioral signals, on the other hand, is not prohibited. The policy above only looks at engagement like "saved 8 favorites" or "spent 90+ calm seconds" — it does not branch on the user's rating itself. The line is simple: gating display behind a satisfaction-probing UI is out; choosing a moment when the user is likely satisfied and offering it equally to everyone is fine. Staying on the right side of that line is, in the end, what protects your account and your app.
Your next move
If you're adding In-App Review for the first time, start by removing the "persist a reviewed flag" from the launchReviewFlow completion callback. That was the source of my half-day of silence. Just swapping in the assumption that this is an API with no display guarantee dissolves all three mysteries — callback, quota, and testing — at once. The trigger policy and collision arbitration can then be tuned to your app's rhythm a little at a time.
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.