Engineering
13 min read

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.

React Native Performance Optimization: Lessons from Building Apps with 50K+ Users
DP

Dibyank Padhy

Engineering Manager & Full Stack Developer

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:

javascript
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:

javascript
// 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.

Found this helpful? Share it with others

DP

About the Author

Dibyank Padhy is an Engineering Manager & Full Stack Developer with 7+ years of experience building scalable software solutions. Passionate about cloud architecture, team leadership, and AI integration.