●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
The Complete Rork App Performance Optimization Guide: Rendering, Memory, and Bundle Size
A comprehensive guide to squeezing every bit of performance out of your Rork app. Covers rendering optimization, memory leak prevention, bundle size reduction, and profiling with Flipper.
When building with Rork, it's easy to focus on shipping features and leave performance tuning for "later." But in a world where users make snap judgments—often in the first few seconds—speed and smoothness are every bit as important as functionality. Google's research shows that bounce rates increase by 32% when page load time grows from one second to three. The same principle applies to native apps.
App Store reviews are brutal when it comes to performance. "Laggy," "slow," and "crashes" are among the most common complaints that sink otherwise excellent apps. Conversely, apps that feel responsive and snappy get disproportionate praise, even when their feature sets are comparable to slower competitors.
This guide is written for developers who are ready to go beyond the basics. We'll cover how to systematically optimize React Native and Expo apps generated by Rork and Rork Max—not just with quick tips, but with the underlying reasoning you need to apply these patterns to your own projects. We'll profile with real tools, measure with real numbers, and make decisions based on data rather than guesswork.
Chapter 1: Understanding the React Native Rendering Cycle
How React Native Renders
React Native runs JavaScript on a dedicated JS thread, separate from the native UI thread. When state or props change, React computes a virtual DOM diff and communicates changes to the UI thread. In the classic architecture, this communication crosses a bridge—a serialization/deserialization bottleneck that can cause frame drops. The New Architecture (JSI + Fabric) reduces this overhead significantly, but the fundamental principle of keeping expensive work off the rendering path remains the same.
The most common performance problem is unnecessary re-renders. By default, when a parent component re-renders, all its children re-render too, even if their props haven't changed. This cascading behavior is harmless at small scales but becomes devastating in complex screens with dozens of components.
React.memo, useCallback, and useMemo are your primary weapons. Understanding when—and when not—to use them is the difference between genuine optimization and premature optimization theater.
Preventing Re-renders with React.memo
// ❌ Bad: Card re-renders every time the parent updates,// even if title and onPress haven't changedconst Card = ({ title, onPress }) => { console.log('Card rendered:', title); return ( <TouchableOpacity onPress={onPress}> <Text>{title}</Text> </TouchableOpacity> );};// ✅ Good: Card only re-renders when its props actually changeconst Card = React.memo(({ title, onPress }) => { console.log('Card rendered:', title); return ( <TouchableOpacity onPress={onPress}> <Text>{title}</Text> </TouchableOpacity> );});// CRITICAL: The parent must also stabilize the onPress reference.// Without useCallback, a new function is created on every render,// which makes React.memo's equality check always fail.const ParentScreen = () => { const [count, setCount] = useState(0); // ❌ New function reference on every render — breaks React.memo // const handlePress = () => console.log('pressed'); // ✅ Stable reference — Card won't re-render due to onPress changing const handlePress = useCallback(() => { console.log('pressed'); }, []); // Empty deps: created once and reused forever return ( <View> <Button onPress={() => setCount(c => c + 1)} title="Increment" /> <Card title="Item" onPress={handlePress} /> {/* Now, pressing the button increments count but Card doesn't re-render */} </View> );};
Memoizing Expensive Computations with useMemo
const ProductList = ({ products, searchQuery, selectedCategory }) => { // ❌ Filtering and sorting runs on every render — even when selectedCategory // changes but searchQuery doesn't // const filtered = products // .filter(p => p.name.includes(searchQuery)) // .sort((a, b) => a.price - b.price); // ✅ Only recomputes when products or searchQuery changes const filtered = useMemo(() => { console.log('Computing filtered products...'); return products .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase())) .sort((a, b) => a.price - b.price); }, [products, searchQuery]); // selectedCategory changes don't trigger this expensive operation return <FlatList data={filtered} renderItem={/* ... */} />;};
When NOT to Use React.memo
This is equally important. React.memo adds a props comparison step on every render. For lightweight components, this overhead can actually exceed the cost of the re-render itself. Rules of thumb:
Do useReact.memo when: the component renders frequently, renders are expensive, or it's a list item rendered dozens of times
Don't useReact.memo when: the component is trivial, props change on almost every render, or the component rarely re-renders
✦
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
✦Deeply understand React Native's rendering cycle and learn proven techniques to eliminate unnecessary re-renders
✦Master profiling with Flipper and the Hermes Profiler to pinpoint bottlenecks on real devices with precision
✦Walk away with a comprehensive checklist for bundle size reduction, memory leak prevention, and maintaining 60fps animations
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.
Chapter 2: Mastering FlatList for Smooth Scrolling
FlatList is the backbone of most list-heavy Rork apps—product catalogs, social feeds, messaging lists. Getting FlatList right has an outsized impact on perceived performance because users spend significant time scrolling.
Declare Item Heights with getItemLayout
Without getItemLayout, FlatList must measure each item's height as it comes into view, causing layout thrashing during scroll. Declaring heights upfront eliminates this entirely.
const ITEM_HEIGHT = 72; // Your item's fixed height in pixelsconst SEPARATOR_HEIGHT = 1; // Height of the separator between items<FlatList data={items} keyExtractor={(item) => item.id.toString()} renderItem={({ item }) => <ListItem item={item} />} ItemSeparatorComponent={() => <View style={{ height: SEPARATOR_HEIGHT }} />} // ✅ Pre-declared heights eliminate layout measurement during scroll getItemLayout={(data, index) => ({ length: ITEM_HEIGHT, offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index, index, })} // ✅ Tuned rendering windows // initialNumToRender: Items rendered before first paint // maxToRenderPerBatch: Items rendered per JS batch during scroll // windowSize: Rendered area = windowSize × visible area initialNumToRender={8} maxToRenderPerBatch={5} windowSize={5} // Render 5× visible area (default 21 is often too much) // ✅ Clip off-screen items from the native view hierarchy removeClippedSubviews={true} // ✅ Hint to the native layer about expected item count // (Helps scroll bar positioning) // estimatedItemSize={ITEM_HEIGHT} // For FlashList users/>
When Items Have Variable Heights
If your items have dynamic heights (e.g., text that wraps), you can't use getItemLayout. In this case, switch to FlashList from Shopify, which handles variable heights far more efficiently than FlatList:
npx expo install @shopify/flash-list
import { FlashList } from '@shopify/flash-list';<FlashList data={items} renderItem={({ item }) => <MessageItem item={item} />} estimatedItemSize={80} // Average estimate is enough for FlashList keyExtractor={(item) => item.id}/>
Memoizing List Items
// ✅ Keep item components pure — React.memo prevents re-renders// when the parent FlatList updates due to unrelated state changesconst ListItem = React.memo(({ item, onPress, onLongPress }) => { return ( <TouchableOpacity onPress={() => onPress(item.id)} onLongPress={() => onLongPress(item.id)} style={styles.item} > <Image source={{ uri: item.imageUrl }} style={styles.thumbnail} /> <View style={styles.content}> <Text style={styles.title}>{item.title}</Text> <Text style={styles.subtitle}>{item.subtitle}</Text> </View> </TouchableOpacity> );}, (prevProps, nextProps) => { // Custom equality: only re-render if the item data actually changed return ( prevProps.item.id === nextProps.item.id && prevProps.item.title === nextProps.item.title && prevProps.item.subtitle === nextProps.item.subtitle );});// Parent: stabilize callbacks to complete the optimizationconst onPressItem = useCallback((id: string) => { navigation.navigate('Detail', { id });}, [navigation]);const onLongPressItem = useCallback((id: string) => { showContextMenu(id);}, []);
Chapter 3: Profiling with Flipper and the Hermes Profiler
You can't optimize what you can't measure. Guessing where performance problems live wastes time and often makes things worse. Flipper gives you the data you need.
Setting Up Flipper
# Install Flipper on macOSbrew install --cask flipper# Launch your Expo app in dev mode on the iOS Simulatornpx expo start --ios# Flipper auto-detects connected React Native apps# In Flipper: Plugins → Add Plugins → search "React Native Performance"
Key Flipper plugins for performance work:
React DevTools: Visualize the component tree, highlight re-renders (look for components flashing frequently)
Hermes Debugger: CPU profiling, heap snapshots
Network: Inspect API calls, response sizes, timing
Databases: Inspect SQLite / AsyncStorage contents
Measuring JS Thread Duration with Performance Markers
import { performance } from 'react-native-performance';// Wrap any operation you want to measureconst processLargeDataset = (dataset: Product[]) => { performance.mark('process-start'); // Expensive operation: filter + sort + transform const result = dataset .filter(p => p.inStock) .sort((a, b) => b.rating - a.rating) .map(p => ({ ...p, displayPrice: `$${p.price.toFixed(2)}` })); performance.mark('process-end'); performance.measure('dataset-processing', 'process-start', 'process-end'); const [measure] = performance.getEntriesByName('dataset-processing'); console.log(`Dataset processing: ${measure.duration.toFixed(2)}ms`); // Expected output for 1000 items: ~2-5ms (well under the 16ms frame budget) // If you see >16ms: move processing to a web worker or break it into chunks return result;};
Key Metrics and What They Mean
JS FPS (JavaScript Frames Per Second)
This measures how fast the JS thread is producing work. At 60fps, you have 16ms per frame. Drop below 45fps during scroll and users will feel it.
Fix: Break expensive JS work into smaller chunks with setInterval or move to a background worker.
UI FPS (UI Thread Frames Per Second)
This measures the native rendering rate. A drop here is especially serious because it creates visible jank—stutters that users immediately notice and associate with "a buggy app."
Fix: Use Reanimated 3 worklets (Chapter 5) to run animations on the UI thread directly.
JS Thread CPU Usage
Spikes above 80% are a warning sign. Sustained high CPU will drain the battery and heat up the device.
Fix: Identify the hot path with Hermes Profiler's flame chart and optimize or defer it.
Hermes Heap Growth
If memory increases linearly over a session without leveling off, you almost certainly have a leak.
Fix: See Chapter 4 on memory leak detection.
Chapter 4: Eliminating Memory Leaks
Memory leaks in React Native apps follow predictable patterns. Once you know them, they're straightforward to prevent.
The Golden Rule: Always Return a Cleanup Function
// ✅ Pattern 1: WebSocket cleanupconst ChatScreen = () => { const [messages, setMessages] = useState<Message[]>([]); useEffect(() => { const socket = new WebSocket('wss://api.example.com/chat'); socket.onopen = () => console.log('Connected'); socket.onmessage = (event) => { const message = JSON.parse(event.data) as Message; setMessages(prev => [message, ...prev]); }; socket.onerror = (error) => console.error('WebSocket error:', error); // ✅ Without this, the socket stays open after navigation return () => { socket.close(); console.log('Socket closed — no leak'); }; }, []); return <FlatList data={messages} renderItem={renderMessage} />;};// ✅ Pattern 2: Timer cleanupconst AutoRefreshList = () => { useEffect(() => { // ❌ This leaks — the interval fires forever, even after unmount // setInterval(() => refreshData(), 30_000); // ✅ Store the ID and clear it on unmount const id = setInterval(() => refreshData(), 30_000); return () => { clearInterval(id); console.log('Interval cleared — no leak'); }; }, []);};// ✅ Pattern 3: Async operation with mounted flagconst DataScreen = () => { const [data, setData] = useState(null); useEffect(() => { let isMounted = true; // Track mount status const loadData = async () => { const result = await fetchExpensiveData(); // ✅ Only update state if the component is still mounted if (isMounted) { setData(result); } }; loadData(); return () => { isMounted = false; // Prevent setState on unmounted component }; }, []);};
// In __DEV__ mode, log heap size periodically to spot upward trendsif (__DEV__) { let sampleCount = 0; const memoryMonitor = setInterval(() => { sampleCount++; // Use Flipper's Memory plugin to visualize the trend visually // This log helps correlate heap growth with user actions console.log(`[Memory sample ${sampleCount}] — check Flipper Memory tab`); // Force GC to see "true" retained memory (dev only) if (global.gc) global.gc(); }, 15_000); // Clean up the monitor itself when the app goes to background AppState.addEventListener('change', (state) => { if (state === 'background') clearInterval(memoryMonitor); });}
Chapter 5: Keeping Animations at 60fps
Animations that stutter destroy the feeling of quality. The root cause is almost always the same: animation logic running on the JS thread, competing with React rendering and data fetching for the same 16ms frame budget.
Understanding the Two Threads
The JS thread handles: React rendering, business logic, API calls, state updates.
The UI thread handles: Native rendering, touch events, layout.
Animations that run on the JS thread must "cross the bridge" to update the UI on every frame—and if the JS thread is busy, frames get dropped. Reanimated 3 solves this by running animation logic as worklets directly on the UI thread, with no bridge involvement.
A bloated bundle directly translates to longer cold start times and larger downloads. Every kilobyte matters on slow connections or budget devices.
Analyze Before You Optimize
Guessing what's large is almost always wrong. Measure first:
# Expo: Export a production bundle and visualize itnpx expo export --platform ios --output-dir ./dist-analysisnpx source-map-explorer 'dist-analysis/_expo/static/js/ios/*.js'# The visualization shows each library's contribution to the bundle# Common surprises:# - moment.js: ~300KB for date formatting you could do with 5 lines# - lodash (full import): ~500KB for array utilities you only need 3 of# - entire icon font: ~200KB when you use 12 icons
Common Offenders and Their Leaner Alternatives
Date formatting
// ❌ moment.js: ~300KB gzippedimport moment from 'moment';const formatted = moment(date).format('MMMM D, YYYY');// ✅ date-fns: tree-shakeable, import only what you useimport { format } from 'date-fns';const formatted = format(date, 'MMMM d, yyyy');// Bundle cost: ~6KB for this one function
Utility functions
// ❌ Full lodash import: ~500KBimport _ from 'lodash';const unique = _.uniqBy(items, 'id');// ✅ Individual import: ~2KBimport uniqBy from 'lodash/uniqBy';const unique = uniqBy(items, 'id');// ✅ Or just use native ES methods when possibleconst unique = items.filter( (item, index, self) => self.findIndex(i => i.id === item.id) === index);
Icon libraries
// ❌ Imports the entire icon font fileimport { AntDesign } from '@expo/vector-icons';// ✅ For production, consider SVG icons (only include what you use)import HeartIcon from './assets/icons/heart.svg';import SearchIcon from './assets/icons/search.svg';
Images are often the single largest contributor to app bundle size.
import { Image } from 'expo-image'; // 3-4× faster than React Native's Image// ✅ expo-image: progressive loading, memory + disk cache, WebP support<Image source={{ uri: 'https://cdn.example.com/product.jpg' }} placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }} contentFit="cover" transition={200} cachePolicy="memory-disk" style={{ width: 200, height: 200 }}/>// ✅ Prefetch images before the user navigates to a screenimport { useEffect } from 'react';const ProductListScreen = ({ products }) => { useEffect(() => { // Prefetch first 10 product images const urls = products.slice(0, 10).map(p => ({ uri: p.imageUrl })); Image.prefetch(urls); }, [products]);};// ✅ Local assets: always provide @2x and @3x variants// assets/logo.png (1× — baseline)// assets/logo@2x.png (2× — for Retina displays)// assets/logo@3x.png (3× — for iPhone Pro Max, etc.)// React Native automatically selects the right one at runtime
Chapter 7: Faster App Launch
Cold start time (the time from tap to first interactive frame) shapes the user's first impression. The goal is under 3 seconds; under 1.5 seconds feels premium.
Profile Your Launch First
# iOS: Use Xcode Instruments → App Launch template# Captures: process launch → first frame rendered → time to interactive# Android: Use adb shell am start-activity -Wadb shell am start-activity -W com.yourapp/.MainActivity# Output shows: ThisTime, TotalTime, WaitTime
Defer Non-Critical Work
import { InteractionManager } from 'react-native';const HomeScreen = () => { const [isFullyReady, setIsFullyReady] = useState(false); useEffect(() => { // ✅ Wait for navigation animations and initial renders to complete // before kicking off expensive initialization const task = InteractionManager.runAfterInteractions(async () => { // These can run in parallel after the screen is interactive await Promise.all([ initAnalytics(), // Analytics SDK setup prefetchRecommendations(), // Pre-warm recommendation cache syncUserPreferences(), // Background sync ]); setIsFullyReady(true); }); return () => task.cancel(); // Cancel if navigating away immediately }, []); return ( <ScrollView> {/* Above-the-fold content: lean and fast */} <HeroSection /> <FeaturedSection /> {/* Below-the-fold content: deferred until screen is interactive */} {isFullyReady ? ( <RecommendationsSection /> ) : ( <RecommendationsSkeleton /> )} </ScrollView> );};
Lazy-Load Heavy Screens
// For Expo Router / web targets, code-split heavy screensimport { lazy, Suspense } from 'react';// The bundle for HeavyAnalyticsScreen is only loaded when neededconst HeavyAnalyticsScreen = lazy( () => import('./screens/HeavyAnalyticsScreen'));const App = () => ( <Suspense fallback={<FullScreenLoader />}> <HeavyAnalyticsScreen /> </Suspense>);
Chapter 8: Network and API Performance
Smart Caching with TanStack Query
Every redundant network request is wasted time and battery. TanStack Query's caching layer ensures your app fetches data only when it actually needs to (see the complete TanStack Query data fetching guide for full setup).
For ongoing production visibility, integrate Sentry's performance monitoring. It captures transactions, slow renders, and network timing without requiring manual instrumentation (see the Sentry crash monitoring setup guide for the integration steps).
Chapter 9: Real-Device Performance Checklist
Simulators mask many real-world issues—thermal throttling, memory pressure from other apps, actual network latency. Always validate on physical hardware before shipping.
Launch Time
Force-quit the app completely from the app switcher
Tap the icon and start a stopwatch
Stop when the first interactive screen is fully rendered
Run 5 times and take the average
Target: under 3 seconds; under 1.5 seconds is excellent
Scroll Performance
Open Xcode → Instruments → Core Animation (iOS) or Android Profiler → GPU rendering
Scroll a large list (100+ items) continuously for 30 seconds
Look for frames that take more than 16ms (red bars in Core Animation)
Target: 95th percentile frame time under 16ms (55+ FPS sustained)
Navigate through all major screens, use all major features for 10 minutes
Watch for: linear memory growth (leak), sudden spikes (allocation storms)
Target: Net memory growth under 50MB per session
Battery and Thermal Impact
Xcode → Instruments → Energy Log
Run a typical user session for 15 minutes
Check for sustained high CPU drain (background fetches, timers)
Target: Energy impact should be "Low" during idle and "High" only during active use
Summary
Performance optimization in Rork apps is a discipline, not a checklist. The techniques in this guide build on each other to create a foundation of speed and reliability:
React.memo + useCallback + useMemo to break unnecessary re-render cascades
FlatList with getItemLayout (or FlashList for dynamic heights) for smooth, 60fps scrolling
Flipper and Hermes Profiler to measure bottlenecks with real data, not intuition
Rigorous useEffect cleanup patterns to prevent memory leaks from accumulating
Reanimated 3 worklets to keep animations entirely on the UI thread
Bundle analysis and Metro configuration to trim dead weight and reduce load times
InteractionManager to defer non-critical startup work without sacrificing launch speed
TanStack Query's caching layer to minimize redundant network requests
Each of these optimizations can be applied incrementally. You don't need to do everything at once—pick the one that will have the biggest impact on your users' experience today, measure the before and after, and keep going.
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.