My star count didn't budge for two weeks.
expo-store-review was in place. The logs showed requestReview() being called dozens of times a day. Yet the rating count in App Store Connect was almost identical to the week before. Calling it but seeing no growth is the worst kind of stuck: there's no error, so there's no thread to pull.
The cause was simple. Calling the review dialog doesn't guarantee the OS shows it, and the OS never tells you whether it did. I had been happily counting requests that never arrived.
These are field notes on making that silent suppression visible for a Rork (React Native / Expo) app, using a metric I could actually control — display opportunities. I'll keep it to the steps that genuinely moved the star count on an app I run as a solo developer.
The call succeeds, but nothing is shown
Let's be precise about the constraints. On iOS, SKStoreReviewController.requestReview() (StoreReview.requestReview() under Expo) behaves as follows.
| Constraint | Detail | Visible to you? |
|---|---|---|
| Annual display cap | Roughly 3 times per year per user | No |
| Final say on showing | The OS may choose not to display it | No |
| Callback | No return value for shown or submitted | Unavailable |
| Dev build | Always shows in the simulator (unlike production) | Misleading |
Google Play's In-App Review API follows the same philosophy. There's a daily quota, and repeating within a short window silently does nothing. It, too, returns no confirmation that anything was shown.
So the number of requestReview() calls is useless as a signal for the review experience. The more you call it, the more of the internal quota you burn — leaving nothing for the moment that matters. That waste was exactly what trapped me for two weeks.
Count display opportunities, not calls
With no return value, you can't capture actual displays. Stop chasing what you can't measure and place a metric one step earlier — one you fully control: the moment you passed your own gate and requested the dialog. Call that a display opportunity, and record it with your own counter.
The reasoning: you can't touch the OS quota, so instead manage the app-side gate (when, for whom, and how often you ask) strictly, and treat only passages through that gate as a trustworthy signal. Narrow the opportunities, and you concentrate the scarce OS quota on high-value moments.
// src/lib/reviewGate.ts
import * as StoreReview from 'expo-store-review';
import AsyncStorage from '@react-native-async-storage/async-storage';
const KEYS = {
opportunities: 'review_opportunities', // times a display opportunity was reached
lastPromptAt: 'review_last_prompt_at', // last prompt time (ISO)
positiveSignals: 'review_positive_signals', // accumulated good experiences
version: 'review_prompted_version', // app version already prompted
};
// These two are your own anti-waste rules, separate from the OS limits.
const MIN_DAYS_BETWEEN = 60; // minimum days since the last prompt
const REQUIRED_SIGNALS = 3; // good experiences required before prompting
async function getNumber(key: string): Promise<number> {
const raw = await AsyncStorage.getItem(key);
return raw ? Number(raw) : 0;
}
// Add a point on each good experience (task done, save succeeded, etc.)
export async function recordPositiveSignal(): Promise<void> {
const current = await getNumber(KEYS.positiveSignals);
await AsyncStorage.setItem(KEYS.positiveSignals, String(current + 1));
}
// Decide whether to ask; if allowed, record the opportunity and request.
export async function maybeRequestReview(appVersion: string): Promise<boolean> {
const isAvailable = await StoreReview.isAvailableAsync();
if (!isAvailable) return false;
const signals = await getNumber(KEYS.positiveSignals);
if (signals < REQUIRED_SIGNALS) return false;
const promptedVersion = await AsyncStorage.getItem(KEYS.version);
if (promptedVersion === appVersion) return false; // never twice on one version
const lastAtRaw = await AsyncStorage.getItem(KEYS.lastPromptAt);
if (lastAtRaw) {
const days = (Date.now() - new Date(lastAtRaw).getTime()) / 86400000;
if (days < MIN_DAYS_BETWEEN) return false;
}
// Count only this passage as a display opportunity.
const opp = await getNumber(KEYS.opportunities);
await AsyncStorage.setItem(KEYS.opportunities, String(opp + 1));
await AsyncStorage.setItem(KEYS.lastPromptAt, new Date().toISOString());
await AsyncStorage.setItem(KEYS.version, appVersion);
await StoreReview.requestReview(); // the OS decides whether it shows
return true;
}The crucial part is sending the count of times maybeRequestReview() returned true to your analytics. Line up that total of display opportunities against the rating deltas in App Store Connect and Play Console, and you can estimate the otherwise invisible reality of displays and submissions.