When you open a Rork-generated app on a real device for the first time, have you ever thought "this feels kind of heavy"? I've been there many times. Apps with AI integration are particularly susceptible — initialization ends up juggling API key validation, model list fetching, and auth token refresh all in parallel, and it's not unusual for the first screen to take three seconds or more to appear.
To start with the conclusion: startup time in Rork-generated apps can be halved in most cases without major code rewrites. This article walks through the process I used, including the measurement logs I captured as I tuned.
First, measure startup time accurately
Everyone knows "optimization starts with measurement," but measuring React Native startup time correctly takes a little know-how. If you judge by gut feel — "this feels faster now" — you'll hit the same wall again a few weeks later.
These are the four timestamps I use.
- T0: The instant the user taps the app icon (iOS
Launch Screenappearing). - T1: JavaScript bundle load complete (when
AppRegistry.runApplicationfires). - T2: The first route component's
onLayoutfires. - T3: The user can first interact (tap, etc.).
For measurement, I've found plain Date.now() combined with InteractionManager.runAfterInteractions more practical than React Native's PerformanceObserver.
// index.js
const t0 = Date.now();
global.__APP_START__ = t0;
// App.tsx
useEffect(() => {
const t1 = Date.now();
console.log(`[startup] bundle->app mount: ${t1 - global.__APP_START__}ms`);
InteractionManager.runAfterInteractions(() => {
console.log(`[startup] interactive: ${Date.now() - global.__APP_START__}ms`);
});
}, []);This mundane log immediately reveals where the heaviness is. My initial measurement showed T0→T3 averaging 3,200ms, broken down as T0→T1 (bundle) 1,100ms, T1→T2 (initial render) 800ms, and T2→T3 (interactive) 1,300ms.
Don't chain await calls on the initial screen
If you open the root component of a Rork-generated app, very often you'll find a useEffect that sequentially awaits user info, theme settings, AI model list, and notification permission. This is the single biggest bottleneck in startup time.
Code written as await A(); await B(); await C(); can be parallelized with Promise.all whenever the calls are independent.
// Before (slow)
const user = await fetchUser();
const theme = await fetchTheme(user.id);
const models = await fetchAvailableModels();
// After (fast)
const [user, models] = await Promise.all([
fetchUser(),
fetchAvailableModels(),
]);
const theme = await fetchTheme(user.id); // Only things depending on user come nextJust this rewrite shaved 800ms for me. When you look carefully at dependencies, the separation between "can parallelize" and "can't parallelize" becomes obvious.
Minimize images loaded at startup
Another big win was how logos and onboarding images were handled right after launch. In code that Rork auto-generates, home-screen and carousel images sometimes end up bundled in the initial JS bundle via require().
Switching them to lazy loading shrinks the JavaScript bundle itself, dramatically improving T0→T1.
// Before (included in bundle)
import heroImage from './assets/hero.png';
<Image source={heroImage} />
// After (fetched after launch)
<Image source={{ uri: 'https://cdn.example.com/hero.png' }} />That said, logos and icons are better kept as require() — otherwise you get a "momentary blank image" UX regression. Realistically, only CDN-ify things that can show up a bit later: carousels, backgrounds, sample images.
Move auth token verification to after render
The trickiest part I encountered in Rork-generated apps was the auth token verification flow. Many implementations wait for verifyToken() to respond before rendering the first screen — but in most cases this can be deferred.
The vast majority of users open the app with a valid token, so rendering optimistically and verifying in the background dramatically improves perceived speed.
// Before
if (\!tokenVerified) return <Splash />; // Waits for network
return <MainApp />;
// After
const [tokenVerified, setTokenVerified] = useState(true); // Optimistically true
useEffect(() => {
verifyToken().then(valid => {
if (\!valid) {
setTokenVerified(false);
navigation.reset({ routes: [{ name: 'Login' }] });
}
});
}, []);
return <MainApp />;This optimistic rendering alone cut T2→T3 by about 600ms. When the token is invalid, the user briefly sees the home screen before being redirected to login — but that transition actually feels natural as UX.
Enable Hermes and ProGuard
On both iOS and Android, reviewing build settings improves startup. Rork-generated defaults aren't guaranteed to be current, so check the following.
Android: In android/app/build.gradle, enable enableHermes: true and enableProguardInReleaseBuilds: true. Hermes pre-compiles JavaScript and measurably shortens startup.
iOS: In ios/Podfile, check :hermes_enabled => true and :fabric_enabled => true (for New Architecture apps only). Hermes helps on iOS too.
In my case, just enabling Hermes cut T0→T1 by 400ms. Many projects already have it on, but apps generated from older Rork templates can have it disabled.
Defer heavy stores until after first paint
Another pattern I see often in Rork apps: the root component rehydrates persisted state (MMKV, AsyncStorage, Redux Persist) synchronously before the first render. For anything beyond a few hundred keys, this becomes visible on mid-range devices.
The fix is to split persisted state into two tiers — "needed to render the first screen" (theme, locale, auth flag) and "needed eventually" (draft history, analytics consent, cache of past chats). Rehydrate the first tier synchronously, load the second tier in InteractionManager.runAfterInteractions.
useEffect(() => {
// Tier 1 already hydrated before mount
InteractionManager.runAfterInteractions(async () => {
await hydrateDraftHistory();
await hydrateAnalyticsConsent();
});
}, []);Checking AsyncStorage.getAllKeys().length on app launch is a quick way to find out if this applies to you. Anything above 300 keys is a smell.
Watch for over-eager font loading
React Native's custom font loading is another silent startup cost. If useFonts() from expo-font awaits three to five custom fonts before rendering, you're adding 200-400ms on the first launch after install. System fonts for the first screen, custom fonts loaded in parallel behind the scenes, is a better default.
Final measurements
In the end, my app's T0→T3 average dropped to 1,050ms — roughly a third of the original 3,200ms.
- T0→T1: 1,100ms → 550ms (Hermes + smaller bundled images)
- T1→T2: 800ms → 300ms (Promise.all parallelization)
- T2→T3: 1,300ms → 200ms (optimistic token verification)
Once you reach this point, users notice — the "feels faster" sense becomes unmistakable. Store reviews started including speed-improvement comments.
Pitfalls I ran into
Measuring on the dev menu–enabled build. React Native's dev mode skips JavaScript compilation and adds remote debugging overhead, sometimes inflating numbers by 40-60%. Always measure on a Release build on a real device. My Debug-build numbers looked fine, but Release-build measurements told a different story.
Cold launch vs. warm launch. iOS and Android treat the app differently depending on whether it's already in memory. Numbers from "launch → background → relaunch within 10 seconds" are warm launches and will look artificially fast. Reboot the device before your final measurement.
Chasing the wrong metric. I once spent a whole evening shaving 50ms off T0→T1 when T2→T3 had 900ms of headroom. Always start from the largest segment. The biggest win usually comes from optimizing what's currently at least 40% of total startup.
Over-parallelizing. Promise.all is powerful, but firing 10 network requests simultaneously on a 4G connection can actually serialize at the device's network stack. I cap concurrency at 4-5 requests for any startup batch, which kept perceived parallelism without hitting the mobile radio ceiling.
Comparing iOS and Android numbers
Something that surprised me: the same Rork-generated app showed meaningfully different startup profiles across iOS and Android, and the biggest wins were on different axes. On iOS, enabling Hermes and deferring the token check gave me the largest gains. On Android, the dominant cost was the JavaScript bundle load itself — ProGuard plus asset pruning did more than any JavaScript-level tweak.
If you're shipping to both platforms, measure independently and be prepared to apply different optimizations. Specifically, on Android I found these three checks more impactful than the JavaScript-side changes:
- Make sure ProGuard/R8 is enabled in the release variant of
build.gradle. - Check that
multiDexEnabled trueis not triggering a DEX-load delay by auditing what's actually in the secondary DEX files. - Confirm that
resConfigsindefaultConfigis narrowed to the locales you actually ship — without this, Android ships every translated string in every support library, bloating the APK by several MB.
On a Pixel 6a running Android 14, these three changes alone took my cold launch from 4,100ms down to 2,800ms before I had even touched the JavaScript layer.
Try this next
Start by measuring T0→T3 on your own app. Having numbers instead of gut feelings makes the priority of each optimization visible. A realistic first target is sub-1,500ms.