My paywall presented perfectly. Right design, right layout, right call-to-action. The only problem: the subscription prices were blank.
Weekly, monthly, annual: each tier showed its period label ("/wk", "/mo", "/yr") and an empty space where the price should be. Here is the strange part. RevenueCat, running side by side in the same app, had already fetched every real price a second earlier. The lifetime tier, sitting in the same paywall, rendered its price fine ($184.99). So the prices existed. Superwall just refused to draw three of them.
That mismatch took nine rounds of debugging to crack. Here is what was actually wrong, so you can skip the nine rounds.
The setup
The app is an Expo / React Native app. The paywall stack is expo-superwall for presentation and A/B testing, with RevenueCat as the single source of truth for entitlements. They talk through Superwall's recommended CustomPurchaseControllerProvider, so a Superwall purchase routes through react-native-purchases and fires RevenueCat's normal listener.
This is a common combination. Superwall owns the paywall UI, RevenueCat owns "who is paying". If you run it, you can hit both of the bugs below.
One Superwall detail matters for the rest of this story. Superwall renders the paywall in a webview and fills in values like {{ products.primary.price }} from a product-variable map it builds from the loaded store products. If that map is empty, the template renders an empty string. An empty string is your blank price. So "blank prices" really means "Superwall built an empty product-variable map", and the whole hunt is about why that map came up empty.
Why I was blind for so long
The first real unlock was not a fix. It was visibility.
Superwall's SDK logs are invisible at the default log level. In my app only RevenueCat logged anything, so logcat looked like Superwall was barely running. "No Superwall product query in the logs" felt like proof of a dead SDK. It was not. The SDK was busy, just silent.
<SuperwallProvider
apiKeys={apiKeys}
options={{
logging: { level: 'debug', scopes: ['all'] },
}}
>
With level: 'debug' and scopes: ['all'], the productsManager, storeKit, and paywallPresentation lines all appeared. Now I could see Superwall building its product variables and coming up empty, right next to RevenueCat fetching the same prices without trouble:
[Superwall] [productsManager] ERROR: Billing client not ready
[RevenueCat] Retrieved productDetailsList: annual ... $85.99 ... freeTrial:7d
[Superwall] [paywallPresentation] productVariables: null
Two billing clients, one ready and one not, in the same process. Lesson one: before you theorize, turn the logs on. A quiet SDK is not an idle SDK.
Bug one: the entitlement status was stuck on Unknown
With a custom purchase controller, you are responsible for telling Superwall the subscription status. Superwall defaults that status to SubscriptionStatus.Unknown at configure time. Its presentation pipeline has an operator, WaitForSubsStatusAndConfig, that blocks until the status is anything other than Unknown. The SDK's own error text says it plainly: if you use a custom purchase controller, set the entitlement status on time.
My sync code did call RevenueCat's getCustomerInfo() once at startup and pushed the result into Superwall. The problem was timing. Purchases.configure in my app is gated behind auth, but the Superwall provider mounts outside the auth provider. So the initial getCustomerInfo() could run before RevenueCat was configured, throw, and get swallowed. The change listener only fires on a change, so the status never moved off Unknown.
While the status sat at Unknown, the present pipeline stalled and the product-variable map came out empty. Empty variables render as blank prices in the webview. That matched exactly what the debug logs showed.
The fix was to seed a concrete status before any paywall can present, retrying until RevenueCat is actually configured:
// retries getCustomerInfo() until RC is configured (bounded ~6s),
// then pushes a concrete ACTIVE / INACTIVE into Superwall
async function seedInitialSubscriptionStatus() {
const info = await getCustomerInfoWhenReady(); // bounded retry loop
const isActive = hasProEntitlement(info);
Superwall.shared.setSubscriptionStatus(
isActive ? activeStatus : inactiveStatus,
);
}
If RevenueCat never configures within the bound, the seed falls back to INACTIVE, so a non-paying user still sees the paywall (the safe default), and the change listener owns every later transition. That was the real fix. The status now reaches Superwall as a known value before the first present, so the pipeline stops stalling.
Bug two: a default that flips between platforms
After the status fix, prices rendered. Sometimes. If I let the app sit for the better part of a minute before opening the paywall, every price showed. Open the paywall fast after a cold launch and it went blank again.
The debug logs explained it. On a fast view, Superwall computed the paywall's product variables before its own store manager had loaded the products. No loaded products meant a null variable map, which meant blank prices. When I waited, RevenueCat had incidentally warmed the shared Google Play product cache by then, so Superwall's later fetch was a cache hit. My "fix" was riding on luck and a warm cache.
The root cause is a default value. In expo-superwall@1.1.5, the default options ship shouldPreload: false. You can read it straight from the package:
// node_modules/expo-superwall/build/src/SuperwallOptions.js:15
DefaultSuperwallOptions.paywalls.shouldPreload = false;
// merge in useSuperwall: { ...DefaultSuperwallOptions.paywalls, ...yourOptions.paywalls }
The native superwall-android SDK preloads paywalls by default, so the expo wrapper quietly flips the behavior. If you pass options without a paywalls key, the merge keeps the false, preload stays off, and products only load at trigger time. The fix is one line, set explicitly:
options={{
paywalls: { shouldPreload: true },
logging: { level: 'warn' },
}}
With preload on, Superwall warms its own products and builds the paywall's variables at launch, independent of RevenueCat's timing. The cold launch logs now tell the opposite story, before any trigger fires:
[Superwall] config_attributes should_preload=true
[Superwall] [paywallPreload] paywallPreload_start
[Superwall] [paywallPreload] paywallPreload_complete
[Superwall] [paywallPresentation] productVariables: { weekly, monthly, annual }
Prices render every time now, fast cold start or slow.
The red herring
For two rounds I chased a webview error: store/document/undefined - Code: 404. It looked like the smoking gun. It was not. That request fires even when prices render correctly. Chasing it cost me real time.
When you debug an SDK you do not own, separate the symptoms that correlate with the bug from the noise that is always there. Reproduce the success state and check whether your "smoking gun" is still firing. If it is, it is noise.
Why lifetime rendered and subscriptions did not
The detail that nagged me the whole time: the lifetime tier always showed its price. Lifetime is a one-time purchase with no base plan or offer, so it follows a simpler resolution path. The subscriptions go through the product-variable substitution that both bugs broke. One blank tier next to one working tier is a strong hint that the problem is in the per-product variable path, not the network or the store account.
The takeaway
Three things to carry into your own Superwall plus RevenueCat integration:
- Turn on
logging: { level: 'debug', scopes: ['all'] }first. The SDK is silent by default, and you cannot fix what you cannot see. - With a custom purchase controller, seed a concrete subscription status before any paywall presents. An Unknown status short-circuits product loading.
- Set
paywalls: { shouldPreload: true }explicitly. The expo wrapper defaults it to false, against the native default, and that single value decides whether your prices survive a fast cold start.
I am building a mobile app solo and writing up the real bugs as I hit them. If you want more of these field notes, follow along.












