A Resolved Promise Is Not a Shown Dialog: Designing Honest In-App Review Flows

I like promises because they look decisive.
They either resolve or reject.
We await them, and for a brief moment JavaScript feels like a world with rules.
Then I worked through the contract of in-app review prompts.
On both Android and iOS, requestReview() can resolve even when the user never saw a review dialog.
The operating system decides whether to display or suppress the prompt, and the app does not receive a “shown” signal.
That means this perfectly ordinary code contains a dangerous ambiguity:
await requestReview();
track("review_dialog_shown");
The event name is a lie. At most, the app knows that it asked the operating system.
This is not a wrapper bug waiting for a clever fix. It is the platform contract. The useful engineering work is therefore not “detect whether the dialog appeared.” It is designing a flow that never needs that impossible answer.
Round 1: Name the Signal You Actually Have
requestReview(): Promise<void> sounds like a command with a result.
In practice, a successful resolution means the request reached the platform flow without one of the observable errors exposed by the wrapper.
On Android, the implementation asks Play Core for review information and then launches the review flow. When that launch completes, the promise resolves. The code comments record the critical detail: the OS may silently rate-limit the dialog.
On iOS, the implementation calls StoreKit's scene-based API when an active UIWindowScene is available, or the older request API on older supported systems.
It resolves after making the request.
Neither side tells JavaScript that the dialog appeared. Neither side tells JavaScript that a rating was submitted.
So the first design correction is linguistic:
review_request_sentmay be honest.review_dialog_shownis not.review_submittedis definitely not.
Analytics cannot manufacture an observation that the platform intentionally withholds. A precise event name is a small form of architecture (and a much cheaper one than explaining a fictional conversion funnel later).
Round 2: Do Not Make the User Wait for an Invisible Event
The same ambiguity can damage the interface.
Imagine a visible “Rate us” button wired directly to requestReview().
The user taps it.
The OS suppresses the native prompt.
The promise resolves.
Nothing appears.
From the application's perspective, the call succeeded. From the user's perspective, the button is broken.
Adding a spinner makes it worse because there is no future signal that can justify the spinner's completion state. Adding “Thanks for rating!” after resolution is worse still because no rating may have happened.
The repository README therefore separates two intents:
- A contextual, best-effort prompt may call
requestReview()after a positive user moment. - An explicit “Rate us” action should open the store listing, where navigation is observable.
That is the key product rule. Use the native prompt when the OS is allowed to decide quietly. Use the store page when the user explicitly asked for a visible destination.
Round 3: Build a Fallback Ladder, Not a Retry Loop
The recommended integration has three different questions:
- Is in-app review available on this device?
- Can the app ask the OS for the native review prompt?
- If that surface is unavailable or errors observably, can the app open the store listing instead?
Those questions map to three API calls:
import { isAvailable, openStoreListing, requestReview } from "react-native-in-app-review-newarch";
async function promptForReview() {
const supported = await isAvailable();
if (!supported) {
await openStoreListing({ appStoreId: "1234567890" });
return;
}
try {
await requestReview();
} catch {
await openStoreListing({ appStoreId: "1234567890" });
}
}
The ladder moves from the least disruptive surface to the most observable one.
It does not attempt to retry requestReview() because no code can distinguish a silent suppression from a shown dialog.
Retrying on “nothing appeared” would require observing “nothing appeared,” which is precisely the signal the platform does not expose. A timeout would only convert uncertainty into a guess.
The fallback should instead fire from information the app really has: isAvailable() returned false, requestReview() rejected with an observable error, or the user selected an explicit store-directed action.
Round 4: Availability Is Platform-Specific
Even isAvailable() does not mean the same thing on both platforms.
In the recorded implementation, Android checks whether the Google Play Store package is installed. The library manifest declares the required package-visibility query so this check can work on Android versions that filter package visibility.
On iOS, the implementation returns true because StoreKit is present on supported devices.
That is not a prediction that the dialog will appear.
It only says the request API is available.
So this assumption is wrong:
if (await isAvailable()) {
// The dialog will be shown.
}
The honest interpretation is narrower:
if (await isAvailable()) {
// This device supports making the best-effort request.
}
Same Boolean, very different product behavior.
Round 5: The Store Listing Needs Its Own Fallback
Opening the store is more observable, but Android still has two possible routes.
The native Play Store URL gives the best experience:
market://details?id=<packageName>
That scheme is not guaranteed to resolve on every device.
The Android implementation catches ActivityNotFoundException and falls back to the HTTPS listing:
https://play.google.com/store/apps/details?id=<packageName>
If both fail, the wrapper rejects with OPEN_STORE_FAILED.
If there is no foreground activity, it rejects with ACTIVITY_NULL before attempting navigation.
iOS has a different shape.
The store-listing method requires a numeric App Store ID and builds an itms-apps:// review URL.
Missing IDs and URL-open failures remain observable typed errors.
This asymmetry is worth preserving rather than hiding behind an over-simplified “open store” promise. The public API may be shared, but useful error handling still depends on the platform branch.
What Not to Build
Once the null signal is understood, several tempting features become obviously dishonest:
- Do not record a dialog impression when
requestReview()resolves. - Do not show a thank-you message based on that resolution.
- Do not block the user while waiting for proof that will never arrive.
- Do not retry inside the same flow because the UI seemed quiet.
- Do not bind an explicit “Rate us” button to a surface the OS may suppress silently.
The absence of an API is part of the API. The platform is telling us that prompt delivery is its decision, not application state.
The Broader Pattern
In-app review is one example of an opaque success contract: the system acknowledges a request without exposing the user-visible outcome.
Push-notification scheduling, background tasks, share sheets, and permission prompts can carry similar distinctions. The exact contracts differ, so they must be checked separately, but the design questions repeat:
- What does resolution prove?
- What outcome remains unobservable?
- Which UI states would falsely depend on that outcome?
- Is there a more observable fallback for explicit user intent?
- Which analytics names stay true under silent suppression?
Promises are useful. They are not evidence of whatever outcome happens to be convenient for the product funnel.
Sometimes await means “the user saw it.”
Here it means “the OS heard us.”
That is enough—once the interface stops pretending it heard an answer back.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.