Building three React Native apps for WellOS taught me that mobile performance is unforgiving. A janky web app is annoying; a janky mobile app is unusable. Field workers in remote locations with older devices don't tolerate lag.
After two years of optimizing React Native apps to run smoothly on devices from 2018, here are the techniques that made the biggest difference.
Profiling First: Measure Before Optimizing
Never optimize blindly. Use React Native's built-in profiler:
// Enable Flipper for debugging
// Install: npm install --save-dev react-native-flipper
// In your app
import { Platform } from 'react-native';
if (__DEV__ && Platform.OS === 'ios') {
require('./ReactotronConfig');
}Or use the Flashlight CLI tool:
npx @perf-profiler/profiler measureThis shows:
- JavaScript thread FPS
- UI thread FPS
- Component render times
- Bridge traffic
Find bottlenecks before fixing them.
List Optimization: The Biggest Performance Win
Lists are where most React Native apps slow down. We had inspection lists with 1000+ items—scrolling was unusable until we optimized.
Use FlatList, Not ScrollView
// Bad: Renders all 1000 items immediately
<ScrollView>
{inspections.map(inspection => (
<InspectionCard key={inspection.id} inspection={inspection} />
))}
</ScrollView>
// Good: Renders only visible items
<FlatList
data={inspections}
renderItem={({ item }) => <InspectionCard inspection={item} />}
keyExtractor={item => item.id}
/>FlatList virtualizes—only renders items in viewport plus a small buffer.
Optimize FlatList with Key Props
<FlatList
data={inspections}
renderItem={({ item }) => <InspectionCard inspection={item} />}
keyExtractor={item => item.id}
// Performance optimizations
removeClippedSubviews={true} // Unmount off-screen items
maxToRenderPerBatch={10} // Render 10 items per batch
updateCellsBatchingPeriod={50} // Batch updates every 50ms
initialNumToRender={15} // Initial items to render
windowSize={5} // Viewport multiplier (5 = 2.5 screens above/below)
// Memory optimization
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>getItemLayout is crucial for fixed-height items—FlatList doesn't need to measure, improving scroll performance by 50%+.
Memoize List Items
// Bad: Re-renders all items when any state changes
const InspectionCard = ({ inspection }) => {
return (
<View>
<Text>{inspection.wellId}</Text>
<Text>{inspection.status}</Text>
</View>
);
};
// Good: Only re-renders when inspection changes
const InspectionCard = React.memo(({ inspection }) => {
return (
<View>
<Text>{inspection.wellId}</Text>
<Text>{inspection.status}</Text>
</View>
);
}, (prevProps, nextProps) => {
// Custom comparison
return prevProps.inspection.id === nextProps.inspection.id &&
prevProps.inspection.status === nextProps.inspection.status;
});For complex items, memoization prevents unnecessary re-renders.
Use FlashList for Even Better Performance
Shopify's FlashList outperforms FlatList:
npm install @shopify/flash-listimport { FlashList } from '@shopify/flash-list';
<FlashList
data={inspections}
renderItem={({ item }) => <InspectionCard inspection={item} />}
estimatedItemSize={80} // Approximate item height
/>FlashList uses a different recycling algorithm, performing 5-10x better on long lists.
Image Optimization
Images are expensive. Our inspection photos were slowing the app until we optimized.
Use Fast Image
npm install react-native-fast-imageimport FastImage from 'react-native-fast-image';
// Bad: Slow image loading
<Image source={{ uri: inspection.photoUrl }} style={styles.image} />
// Good: Cached, faster loading
<FastImage
source={{
uri: inspection.photoUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable,
}}
style={styles.image}
resizeMode={FastImage.resizeMode.cover}
/>FastImage uses native image libraries (SDWebImage on iOS, Glide on Android) for better caching and performance.
Optimize Image Sizes
Don't load full-resolution images for thumbnails:
const getImageUrl = (url: string, size: 'thumbnail' | 'medium' | 'full') => {
const sizes = {
thumbnail: { width: 150, height: 150 },
medium: { width: 500, height: 500 },
full: { width: 2000, height: 2000 },
};
// Use server-side resizing (Cloudinary, Imgix, etc.)
return `${url}?w=${sizes[size].width}&h=${sizes[size].height}`;
};
<FastImage
source={{ uri: getImageUrl(inspection.photoUrl, 'thumbnail') }}
style={{ width: 100, height: 100 }}
/>Loading a 150x150 thumbnail instead of 2000x2000 photo saves megabytes of bandwidth and memory.
Lazy Load Images
Load images only when visible:
const LazyImage = ({ uri, style }) => {
const [isVisible, setIsVisible] = useState(false);
return (
<View
onLayout={() => setIsVisible(true)}
style={style}
>
{isVisible && (
<FastImage source={{ uri }} style={style} />
)}
</View>
);
};Or use react-native-visibility-sensor.
Reduce JavaScript Bundle Size
Smaller bundles = faster startup.
Use Hermes Engine
Hermes compiles JavaScript to bytecode, reducing app size and improving startup:
// android/app/build.gradle
project.ext.react = [
enableHermes: true, // Enable Hermes
]
// ios/Podfile
use_react_native!(
:hermes_enabled => true
)Hermes reduced our Android APK by 30% and improved startup time by 40%.
Code Splitting with React.lazy
// Bad: Import everything upfront
import InspectionDetailScreen from './screens/InspectionDetailScreen';
import ReportScreen from './screens/ReportScreen';
// Good: Lazy load screens
const InspectionDetailScreen = React.lazy(() => import('./screens/InspectionDetailScreen'));
const ReportScreen = React.lazy(() => import('./screens/ReportScreen'));
// Use with Suspense
<Suspense fallback={<LoadingSpinner />}>
<InspectionDetailScreen />
</Suspense>Screens users don't visit immediately don't need to be in the initial bundle.
Remove Console Logs in Production
Console logs impact performance. Remove them:
npm install babel-plugin-transform-remove-console --save-dev// babel.config.js
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
['transform-remove-console', { exclude: ['error', 'warn'] }],
],
};This removes console.log but keeps console.error and console.warn.
Optimize Re-Renders
Unnecessary re-renders kill performance. Use React DevTools Profiler to find them.
Memoize Expensive Computations
const InspectionList = ({ inspections }) => {
// Bad: Recalculates on every render
const completedCount = inspections.filter(i => i.status === 'completed').length;
// Good: Only recalculates when inspections change
const completedCount = useMemo(
() => inspections.filter(i => i.status === 'completed').length,
[inspections]
);
return <Text>Completed: {completedCount}</Text>;
};Stabilize Callbacks with useCallback
const InspectionList = ({ inspections }) => {
// Bad: New function on every render
const handlePress = (id) => {
navigate('InspectionDetail', { id });
};
// Good: Stable function reference
const handlePress = useCallback((id) => {
navigate('InspectionDetail', { id });
}, [navigate]);
return (
<FlatList
data={inspections}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => handlePress(item.id)}>
<Text>{item.wellId}</Text>
</TouchableOpacity>
)}
/>
);
};Without useCallback, FlatList re-renders all items because handlePress reference changes.
Use Context Wisely
Context causes re-renders of all consumers. Split contexts:
// Bad: Entire app re-renders when user or settings change
const AppContext = createContext({ user, settings, theme });
// Good: Split contexts
const UserContext = createContext(user);
const SettingsContext = createContext(settings);
const ThemeContext = createContext(theme);
// Components only re-render when their specific context changesOptimize Animations
Smooth 60 FPS animations are crucial for perceived performance.
Use Reanimated Instead of Animated
// Bad: JavaScript thread animations (janky)
import { Animated } from 'react-native';
const opacity = new Animated.Value(0);
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true, // Helps, but still JavaScript-based
}).start();
// Good: Native thread animations (smooth)
import Animated, { useSharedValue, withTiming } from 'react-native-reanimated';
const opacity = useSharedValue(0);
opacity.value = withTiming(1, { duration: 300 });Reanimated 2 runs animations on the UI thread, not JavaScript thread, preventing drops during heavy JS work.
Always Use Native Driver When Possible
Animated.timing(translateY, {
toValue: 100,
duration: 300,
useNativeDriver: true, // Critical!
}).start();useNativeDriver: true moves animation to native thread. Only works for transform and opacity, not layout properties.
Native Module Optimization
Some operations must be native for performance.
Move Heavy Computation to Native
We needed to process large datasets from SQLite. JavaScript was too slow:
// ios/DataProcessor.m
@implementation DataProcessor
RCT_EXPORT_MODULE();
RCT_EXPORT_METHOD(processInspections:(NSArray *)inspections
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// Heavy processing in background thread
NSArray *processed = [self doHeavyProcessing:inspections];
dispatch_async(dispatch_get_main_queue(), ^{
resolve(processed);
});
});
}
@endThis reduced processing time from 5 seconds to 500ms.
Batch Bridge Calls
The React Native bridge is a bottleneck. Batch calls:
// Bad: 100 bridge calls
for (const inspection of inspections) {
await NativeModules.Database.save(inspection);
}
// Good: 1 bridge call
await NativeModules.Database.batchSave(inspections);Memory Management
Memory leaks cause crashes on low-end devices.
Clean Up Listeners
useEffect(() => {
const subscription = NetInfo.addEventListener(state => {
setIsConnected(state.isConnected);
});
// Clean up!
return () => subscription();
}, []);Forgetting cleanup causes memory leaks.
Limit State in Large Lists
Don't store unnecessary state for list items:
// Bad: Storing expanded state for 1000 items
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
// Good: Store only expanded items
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
// Most items aren't expanded, so Set stays smallStartup Optimization
First impression matters. Optimize app startup:
Defer Non-Critical Initialization
const App = () => {
useEffect(() => {
// Render UI first, then initialize analytics
InteractionManager.runAfterInteractions(() => {
Analytics.init();
CrashReporting.init();
});
}, []);
return <AppNavigator />;
};InteractionManager.runAfterInteractions waits until UI is interactive.
Use Splash Screen Wisely
Keep splash screen visible while loading critical data:
import SplashScreen from 'react-native-splash-screen';
const App = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
const initialize = async () => {
await loadCriticalData();
setReady(true);
SplashScreen.hide();
};
initialize();
}, []);
if (!ready) return null;
return <AppNavigator />;
};Performance Monitoring in Production
Use Sentry or Firebase Performance:
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.2,
});
// Track screen load times
const InspectionDetailScreen = () => {
useEffect(() => {
const transaction = Sentry.startTransaction({
name: 'InspectionDetailScreen',
op: 'screen.load',
});
return () => {
transaction.finish();
};
}, []);
// ...
};This identifies slow screens in production.
Results
After applying these optimizations to WellOS:
- FlatList scrolling: 60 FPS on all devices (was 15-30 FPS)
- App startup: 1.2s → 0.6s
- Bundle size: 40MB → 28MB (Android)
- Memory usage: Reduced by 35%
- Crash rate: Dropped from 2.5% to 0.3%
Lessons Learned
- Profile first: Don't guess, measure
- Lists are critical: FlatList optimization gives biggest wins
- Images are expensive: Optimize sizes, use FastImage
- Hermes is essential: Enable it on both platforms
- Test on real devices: Emulators lie about performance
Conclusion
React Native can be performant, but it requires discipline. Optimize lists, images, bundle size, and animations. Profile regularly, especially on low-end devices.
After 27 years of development and two years optimizing React Native apps, I've learned that mobile performance isn't optional—it's the difference between an app users love and one they uninstall.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.