← Back to Blog
React Native Performance Optimization Techniques

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 measure

This 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-list
import { 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-image
import 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 changes

Optimize 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);
    });
  });
}
 
@end

This 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 small

Startup 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

  1. Profile first: Don't guess, measure
  2. Lists are critical: FlatList optimization gives biggest wins
  3. Images are expensive: Optimize sizes, use FastImage
  4. Hermes is essential: Enable it on both platforms
  5. 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.

Share this article

Help others discover this content


Jason Cochran

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.