React Native Performance Optimization: Lessons from Building Apps with 50K+ Users
Practical performance optimization strategies for React Native apps, based on real-world experience building Marathon Kids and LivingTree for K-12 schools.
Dibyank Padhy
Engineering Manager & Full Stack Developer
Table of Contents
Performance is a Feature, Not an Afterthought
When we launched Marathon Kids - a fitness tracking app for K-12 schools - we hit a wall at around 10,000 active users. The app was sluggish, list scrolling was janky, and teachers were complaining that it took too long to log activities for their entire class. We had built the app with React Native for rapid cross-platform development, but we had not invested in performance from day one.
The optimization journey that followed taught me more about React Native internals than two years of feature development. Here are the battle-tested strategies that took us from 10K struggling users to 50K+ with a smooth experience.
Understanding the React Native Bridge
Before optimizing anything, you need to understand why React Native apps can feel slow. The architecture has two threads - the JavaScript thread running your React code and the native UI thread rendering views. Communication between them happens over an asynchronous bridge (or in the new architecture, via JSI). Every time data crosses this boundary, there is a cost.
The single most impactful optimization principle: minimize bridge traffic. Every unnecessary re-render, every redundant style calculation, every prop that changes when it should not - all of these generate bridge traffic that slows down your app.
Strategy 1: Virtualized Lists Done Right
FlatList is the backbone of most React Native apps, and it is also the number one source of performance problems. Here is how we optimized our activity feed:
import React, { useCallback, useMemo } from 'react';
import { FlatList, StyleSheet } from 'react-native';
const ITEM_HEIGHT = 80; // Fixed height for each row
const ActivityFeed = ({ activities }) => {
// Pre-calculate layout to avoid measurement during scroll
const getItemLayout = useCallback((_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}), []);
// Stable key extractor prevents unnecessary re-renders
const keyExtractor = useCallback((item) => item.id, []);
// Memoize renderItem to prevent recreation on parent re-render
const renderItem = useCallback(({ item }) => (
<ActivityRow activity={item} />
), []);
return (
<FlatList
data={activities}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
// Performance tuning
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews={true}
initialNumToRender={15}
// Prevent scroll indicator from causing re-renders
showsVerticalScrollIndicator={false}
/>
);
};
// Critical: Memoize the row component
const ActivityRow = React.memo(({ activity }) => {
return (
<View style={styles.row}>
<Text style={styles.title}>{activity.title}</Text>
<Text style={styles.distance}>{activity.distance} miles</Text>
</View>
);
});Strategy 2: Image Optimization
Images were our second biggest performance bottleneck. Teachers were uploading photos of their classes during activities, and loading a feed full of unoptimized images was killing performance.
Use react-native-fast-image instead of the built-in Image component - it adds aggressive caching and priority loading
Resize images server-side before delivery - never send a 4000x3000 photo to a mobile device that will display it at 400x300
Implement progressive loading - show a tiny blurred placeholder, then load the full image
Preload images that are likely to be viewed next using the priority queue
Strategy 3: Reduce JavaScript Bundle Size
A large JavaScript bundle means a longer startup time. For Marathon Kids, we cut our bundle size by 40% with these techniques:
Enable Hermes engine - it compiles JavaScript to bytecode ahead of time, reducing parse time by 50%+
Use lazy loading with React.lazy() for screens that are not immediately visible
Audit dependencies ruthlessly - we found that moment.js added 300KB to our bundle and replaced it with date-fns with tree-shaking
Enable inline requires in Metro bundler to defer module loading until actually needed
Strategy 4: State Management for Performance
Global state changes cause global re-renders. In an app with 50+ components on screen, this is devastating. We restructured our state management to minimize the blast radius of updates:
// BAD: One giant context that triggers re-renders everywhere
const AppContext = React.createContext({
user: null,
activities: [],
notifications: [],
settings: {},
});
// GOOD: Split into focused contexts
const UserContext = React.createContext(null);
const ActivitiesContext = React.createContext([]);
const NotificationsContext = React.createContext([]);
// BETTER: Use Zustand with selectors for surgical updates
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
activities: [],
notifications: [],
setUser: (user) => set({ user }),
addActivity: (activity) => set((state) => ({
activities: [activity, ...state.activities]
})),
}));
// Component only re-renders when activities change
const ActivityCount = () => {
const count = useStore((state) => state.activities.length);
return <Text>{count} activities</Text>;
};Strategy 5: Native Module Optimization
Some operations should never run on the JavaScript thread. For Marathon Kids, GPS tracking and step counting were running in JavaScript, causing the main thread to stutter during tracking sessions.
We moved these to native modules - a custom Swift module for iOS and Kotlin for Android that handles all sensor data collection in the background. The JavaScript side only receives aggregated updates once per second instead of processing raw sensor data 50+ times per second.
Results: Before and After
After implementing all five strategies across Marathon Kids:
App startup time: 4.2s to 1.8s (57% reduction)
Feed scroll FPS: 34 FPS to 58 FPS (70% improvement)
JavaScript bundle size: 2.8MB to 1.7MB (40% reduction)
Memory usage: 180MB peak to 95MB peak (47% reduction)
App Store rating: 3.2 to 4.6 stars
The lesson I took away from this: React Native performance optimization is not about one silver bullet. It is about dozens of small, intentional decisions that compound. Every unnecessary re-render you eliminate, every bridge crossing you avoid, every byte you trim from your bundle - they all add up to an app that feels native.
Stay Updated
Get notified when I publish new articles on engineering, AI, and leadership. No spam, unsubscribe anytime.