●ACQUISITION — Rork makes its first acquisition, buying Paperline, a macOS app that generates native Swift apps with AI●FUNDING — The $15M seed led by Left Lane Capital backs Rork's push to redefine how mobile apps are built and monetized●GROWTH — Rork Max reportedly hit $1.5M ARR within three days of launch and doubled annual revenue in two weeks●ENGINE — Rork Max runs on Claude Code and Claude Opus 4.6, the first web Swift builder aiming to replace Xcode●SPLIT — Standard Rork uses React Native (Expo); Rork Max generates native Swift across the whole Apple ecosystem●PRICING — Start free; paid plans begin at $25/month, with Rork Max at $200/month●ACQUISITION — Rork makes its first acquisition, buying Paperline, a macOS app that generates native Swift apps with AI●FUNDING — The $15M seed led by Left Lane Capital backs Rork's push to redefine how mobile apps are built and monetized●GROWTH — Rork Max reportedly hit $1.5M ARR within three days of launch and doubled annual revenue in two weeks●ENGINE — Rork Max runs on Claude Code and Claude Opus 4.6, the first web Swift builder aiming to replace Xcode●SPLIT — Standard Rork uses React Native (Expo); Rork Max generates native Swift across the whole Apple ecosystem●PRICING — Start free; paid plans begin at $25/month, with Rork Max at $200/month
Why Reinstalling Users Don't Return to 'First Run' — A First-Launch and State-Persistence Design for Rork (Expo) Apps
How to fix broken first-launch detection, onboarding, and free-trial state on reinstall in a Rork-generated Expo app, by treating it as an asymmetry in storage persistence. Covers what survives uninstall on iOS vs Android, separating install and version axes, and server-authoritative entitlements, with implementation.
Why Reinstalling Users Don't Return to "First Run"
One day I got a message about one of my indie apps: "I switched phones and onboarding never showed." Normally you worry about the opposite — onboarding showing again on reinstall is the more common complaint. Digging in, the cause was that each storage type differs in whether it's wiped or kept on uninstall. The flag I used for first-launch detection was wiped on one device and survived on another.
What clicked then was that reinstall bugs are less bugs than the result of the developer not grasping the asymmetry of storage persistence. This article tackles that asymmetry head-on and shares the design — with implementation — for keeping first-launch detection, onboarding, and free trials from breaking on reinstall.
What Is "Wiped" and What "Survives" on Uninstall
Get the facts straight first. When an Expo/React Native app is deleted, what's wiped and what survives differs by storage and OS. iOS Keychain in particular is counterintuitive: items can survive even after the app is removed.
Storage
After uninstall on iOS
After uninstall on Android
AsyncStorage / MMKV / files
Wiped
Wiped
SecureStore (Keychain)
Can survive
Wiped
SharedPreferences
—
Can be restored via auto-backup
iCloud Keychain synced items
Survive across devices
—
Server-side account
Survives (device-independent)
Survives (device-independent)
This table explains nearly all reinstall behavior. Put a first-launch flag in AsyncStorage and it's wiped on reinstall, so onboarding shows again. Put "trial consumed" in SecureStore and on iOS it survives deletion, so the trial doesn't come back after reinstall (whether you want it back is a design decision). On Android SecureStore is wiped too, but SharedPreferences can be restored depending on the auto-backup setting.
In other words, "wiped or kept" is a property determined by your storage choice — not chance. The design starts here: for each piece of state, decide up front whether it should be cleared or survive on reinstall, then place it in the storage that matches.
Decide "How It Should Behave" First
Desired behavior differs by state. Every time I add a piece of state, I ask myself three questions.
Should this state be reset on reinstall (e.g., local drafts, UI settings)?
Should this state survive reinstall (e.g., an identifier to prevent fraudulent trial re-acquisition)?
Should this state be held by the server, not the device (e.g., purchases, subscriptions, accounts)?
The table below is my default assignment.
State
Desired behavior
Where it lives
Onboarding completed
Fine to re-show on reinstall
AsyncStorage
UI settings / theme
Fine to clear
AsyncStorage
Free trial consumed
Should survive (prevent re-acquisition)
Server (SecureStore as aux)
Purchase / subscription entitlement
Survives, device-independent
Server / store (StoreKit/Play)
Install identifier
Should survive on the same device
SecureStore
The key point is never judge money- or fraud-related state with device storage alone. AsyncStorage is wiped on reinstall, so putting "trial used" there lets a user re-acquire the trial infinitely by deleting and reinstalling. Rely on SecureStore alone, on the other hand, and behavior splits between iOS and Android. Money decisions should ultimately be held by the server, with device storage demoted to a cache for speed.
✦
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
✦Lays out the asymmetry between storage that is wiped on uninstall and storage that survives — per iOS and Android — and shows structurally why first-launch detection breaks on reinstall
✦A durable install-identity implementation that never confuses fresh/update/reinstall, plus server-authoritative trial and entitlement checks — the exact shape I run on my own indie apps
✦Treats first launch (install axis) and post-update What's New (version axis) as separate axes, with Android auto-backup pitfalls and a migration checklist in one place
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.
"First launch" is one phrase, but in practice there are three distinct situations. Handle them with a single flag and something always breaks.
Fresh install (first run): first launch ever on this device
First launch after an update: the app version rose within the same install
Reinstall: deleted once and installed again
The foundation for telling these apart is a durable install identifier in SecureStore. Because SecureStore can survive deletion on iOS, we turn that into "reinstall on the same device" detection.
// install-identity.ts — resolve the install situation as three statesimport * as SecureStore from "expo-secure-store";import AsyncStorage from "@react-native-async-storage/async-storage";import * as Application from "expo-application";import { randomUUID } from "expo-crypto";type InstallState = "fresh" | "update" | "reinstall";export async function resolveInstallState(): Promise<{ state: InstallState; installId: string;}> { const currentVersion = Application.nativeApplicationVersion ?? "0"; // SecureStore can survive deletion on iOS (a persistent layer tied to the device) let installId = await SecureStore.getItemAsync("install_id"); // AsyncStorage is always wiped on delete (a volatile layer tied to the install) const lastVersion = await AsyncStorage.getItem("last_version"); let state: InstallState; if (lastVersion === null) { // AsyncStorage empty = first launch for this install // If an id remains in SecureStore, it's a reinstall on the same device state = installId ? "reinstall" : "fresh"; } else if (lastVersion !== currentVersion) { state = "update"; } else { state = "fresh"; // strictly a normal launch; let the caller decide handling } if (!installId) { installId = randomUUID(); await SecureStore.setItemAsync("install_id", installId); } await AsyncStorage.setItem("last_version", currentVersion); return { state, installId };}
The crux is how the asymmetry is used. Whether AsyncStorage (the wiped layer) is empty tells you "is this the first launch for this install"; whether an id exists in SecureStore (the surviving layer) tells you "is this a reinstall on the same device." Fresh means both empty; reinstall means AsyncStorage empty and SecureStore filled.
Save expo-application's nativeApplicationVersion in AsyncStorage and update detection comes from the same function. Now "show only on first launch" and "show on every update" sit on separate axes.
Onboarding Is on the Install Axis; What's New Is on the Version Axis
People conflate them, but onboarding and the post-update "What's New" belong to different axes. Onboarding is tied to the install; What's New is tied to the version. Hold them separately and both behave correctly.
// gates.ts — manage the two axes with separate flagsexport async function shouldShowOnboarding(): Promise<boolean> { // Install axis: fine to re-show on reinstall, so AsyncStorage is enough const done = await AsyncStorage.getItem("onboarding_done"); return done !== "1";}export async function shouldShowWhatsNew(currentVersion: string): Promise<boolean> { // Version axis: keep "which version was seen" const seen = await AsyncStorage.getItem("whatsnew_seen_version"); return seen !== currentVersion;}
Putting onboarding in AsyncStorage is deliberate. Reinstalling users are often better served by seeing the flow again (someone who bailed early and deleted is, on reinstall, signaling a second attempt). Hold What's New by "seen version" and it appears once per update and not on normal launches. The message at the top of this article — onboarding not showing on reinstall — was a textbook case of having put the onboarding-completed flag into SecureStore. Place install-axis state in the surviving layer and reinstalls no longer return to "first run."
Make the Server Authoritative for Trials and Entitlements
Trial consumption and purchase entitlements must not be judged with device storage. AsyncStorage is wiped on delete, so the trial becomes re-acquirable; SecureStore splits between iOS and Android. Make the server the single authority here.
// trial.ts — query the server keyed by the device identifier (don't use device storage to decide)export async function checkTrialEligibility(installId: string): Promise<{ eligible: boolean; reason: "new" | "consumed" | "active";}> { const res = await fetch("https://api.example.com/trial/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ installId }), }); if (!res.ok) { // On no connectivity, fail to "not granted" (prefer a UX hit over fraud) return { eligible: false, reason: "consumed" }; } return res.json();}
Failing to eligible: false (not granted) on no connectivity is the design decision. Fail the other way and you've opened a hole where a trial can be acquired infinitely in airplane mode. For purchases and subscriptions, make the store entitlement (StoreKit/Play, or an entitlement manager like RevenueCat) the primary source rather than your own server, with the device merely caching the result. The device cache exists only to speed up launch; the final verdict on truth lives server-side.
The Android Auto-Backup Pitfall
iOS Keychain survival is famous, but Android has a symmetric trap. With Android Auto Backup enabled, SharedPreferences contents can be restored through the cloud, so some state "survives" even on reinstall. State you wrote assuming it's wiped on iOS can get restored on Android, and behavior splits.
Explicitly exclude sensitive state you don't want restored via the backup rules in android/app/src/main/res/xml/.
<!-- backup_rules.xml — exclude trials and identifiers from cloud restore --><full-backup-content> <exclude domain="sharedpref" path="trial_state.xml" /> <exclude domain="sharedpref" path="install_identity.xml" /></full-backup-content>
Always verify on real devices which storage survives "on which OS, under which operation." This area behaves differently on simulators/emulators than on real hardware, so whenever I add a new piece of state, I run a "delete → reinstall" on a real device once and confirm with my own eyes that first-launch detection and the trial behave as intended.
Migration and Verification Checklist
Finally, the steps for introducing this design into an existing app.
Inventory which storage your current first-launch flag lives in (AsyncStorage or SecureStore).
Reassign each state to "should be wiped / should survive on reinstall" (use the table above).
Separate onboarding-completed into AsyncStorage (install axis) and What's New onto the version axis.
Move trial and entitlement decisions from device to server, demoting the device to a cache.
Use Android backup rules to exclude sensitive state you don't want restored.
Run all three paths on a real device — fresh / update / delete-then-reinstall — and verify each flag's behavior.
Confirm in airplane mode that the trial check fails to the "not granted" side on no connectivity.
What pays off in this inventory is the view that storage choice is the spec itself. The moment you decide where to put something, its reinstall behavior is already decided.
Closing
Reinstall bugs look like individual defects, but the root is "not grasping the asymmetry between the wiped layer and the surviving layer." Decide desired behavior per state, place it in the matching layer, and make the server authoritative for money and fraud. Split the install axis from the version axis. That tidy-up alone clears a whole class of symptoms: not returning to first run on reinstall, re-acquirable trials, update notices that never appear.
Start by checking whether your app's first-launch flag lives in AsyncStorage or SecureStore. The placement of a single flag quietly governs the reinstall experience. As an indie developer, I hope it helps anyone growing an app that's meant to be used for a long time.
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.