◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Business
⟐ Business/2026-04-11Advanced

Retention and LTV for Rork Apps — Separating the Two Numbers I Kept Confusing

Install retention and subscription retention use different denominators. Starting from the LTV miscalculation that came from mixing them, this walks through reading Day1/Day7/Day30 correctly, costing in generation credits, and catching churn signals early — from an indie developer's desk.

retention10LTV7growth5indie dev30monetization47push notifications11onboarding3analytics5

✦ Premium Article

I had two dashboards open, and I was convinced my app was broken. Firebase showed Day30 retention hovering around 4%. RevenueCat showed retention at 78%. Same app, same month.

For a while I assumed the second number was instrumented wrong. What was actually wrong was my reading of them. One counts everyone who installed. The other counts only people who started paying. They overlap, but they answer different questions.

That mix-up flows straight into your LTV math. Put a retention rate with the wrong denominator into the formula and the number that comes out is off by a multiple. So let's start by pulling the two apart, then rebuild retention and LTV design for a Rork-built app on top of that.

The two retention numbers I was reading on one scale

Here are the two side by side.

— Install retention Subscription retention
Denominator Everyone who installed on a given day Everyone who started paying in a given month
What it counts Did they open the app X days later? Did the next billing cycle renew?
Usual source Firebase Analytics / App Store Connect RevenueCat / App Store Connect subscription reports
Typical range 4–8% at Day30 70–85% monthly
What moves it Onboarding, habit formation, notifications Price, cancel flow, failed-payment recovery

The order-of-magnitude gap makes sense once you see it. Install retention measures whether people who arrived for free stick around. Subscription retention measures whether people who already decided to pay keep paying. The second group is what's left after surviving the first.

The practical damage shows up in the LTV formula. I dropped install retention into a slot that wanted subscription churn, computed an absurdly short average lifetime, and concluded that subscriptions didn't suit my app. In reality, the people who reached checkout stuck around fine. What didn't work wasn't the subscription — it was the path leading up to it.

Recounting what "active" actually contains

The reason I couldn't quite disbelieve that 78% is that it described people who can currently use the app — not people who will pay again. At the end of one month I sorted the RevenueCat event log by date and finally looked at the composition. Three genuinely different states were sitting inside that single active count.

Some subscribers are on track to renew. Some have already tapped cancel and are using the app until their expiration date. Some have a failed payment and are being held in a billing grace period. The last two groups are restricted in no way at all inside the app — and, it turned out, in no way inside my numbers either.

I split the end-of-period active count three ways for one of my apps and lined up three months.

Month Active at period end Expected to renew Cancel scheduled Billing issue (grace) Expected-renewal rate
June 141 114 22 5 80.9%
July 136 110 19 7 80.9%
August 128 98 24 6 76.6%

What surprised me was the steadiness: roughly a fifth of the active count is always made up of people who have already decided to leave. And because they drop on their expiration dates, they leave in clusters — so a monthly average reads as "last month was fine, this month is bad" when nothing actually changed. I noticed it in a month where a group who had been sitting in cancel-scheduled for three weeks all disappeared on the same day.

Retention counts who can use the app. The expected-renewal rate counts who will pay next month. The LTV formula wants the second one. After swapping it in, the figure I had been looking at came down to about four-fifths of what I thought it was.

Splitting the three apart on the client takes a few lines.

// Pull only "expected to renew" out of a RevenueCat EntitlementInfo.
import analytics from '@react-native-firebase/analytics';
import Purchases, {
  CustomerInfo,
  EntitlementInfo,
} from 'react-native-purchases';
 
export type RenewalState =
  | 'renewing'         // on track to renew next period
  | 'cancel_scheduled' // cancelled, still usable until expiration
  | 'billing_issue'    // payment failed, held by the grace period
  | 'trial';           // free period, first charge not yet cleared
 
export function classifyRenewal(e: EntitlementInfo): RenewalState {
  if (e.periodType === 'TRIAL' || e.periodType === 'INTRO') return 'trial';
  if (e.billingIssueDetectedAt) return 'billing_issue';
  if (e.unsubscribeDetectedAt || e.willRenew === false) return 'cancel_scheduled';
  return 'renewing';
}
 
export async function tagRenewalState(entitlementId = 'pro') {
  const info: CustomerInfo = await Purchases.getCustomerInfo();
  const e = info.entitlements.active[entitlementId];
  if (!e) return null; // non-subscribers exit here
 
  const state = classifyRenewal(e);
  // Never used for access control — this is a label for counting only.
  await analytics().setUserProperty('renewal_state', state);
  return { state, expiresAt: e.expirationDate };
}

One decision matters more than the code: entitlement checks stay on isActive, and the classification lives only in renewal_state. Take access away from someone in a grace period and you've evicted a person who would have come back the moment their card cleared. Access is decided by isActive; the split exists only when I count — I had that order backwards for a month once, and a support email is how I found out.

Rather than sampling once at month end, I'd rather accumulate the CANCELLATION, BILLING_ISSUE, and RENEWAL webhooks daily. The client-side check only sees people who open the app, so it misses the cancellations of people who have stopped opening it. I keep both: webhooks for the daily count, the SDK for what the screen shows.

I'd also rather not read a scheduled cancellation as a complaint. Using out the period you already paid for is a calm decision, and dropping a discount into that moment may only disturb it. I send nothing to people in cancel-scheduled, then once — after the expiration date has passed — a note about what's new.

✦

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
✦The install-retention vs subscription-retention mix-up that broke my LTV math, and the step-by-step way to pull the two apart
✦How to load tool subscriptions and generation credits into the C of LTV/CAC, plus the rule I use to decide between building the next app and fixing the current one
✦A silent analytics failure caused by a reserved event name in @react-native-firebase/analytics, and the correct way to log purchase events
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.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork 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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Business2026-05-11
Building Fortune & Manifestation Apps with Rork — Daily Delivery Design That Survives Day Three
Fortune apps lose most users in three days. A Rork design for daily delivery, push timing, and widgets that turns the niche into a habit.
⟐ Business2026-05-15
Rebuilding a Live Wallpaper App with Rork Max: Measured Hours and Post-Relaunch Metrics
I rebuilt a wallpaper app that was already live using Rork Max, timing every stage. Measured hours per phase, the spec details the generated code got wrong, how existing subscribers were migrated safely, and 30-day post-relaunch metrics.
⟐ Business2026-05-05
Running iOS and Android Simultaneously with Rork Max — A Dual-Store Revenue Strategy
Expanding a Rork Max app to both iOS and Android: platform-specific monetization, RevenueCat and AdMob integration, and a monthly revenue rollup built on RevenueCat webhooks. Written from the reporting mistakes I had to fix first.