Optimizing React Native Performance: Lessons from Production
Ekool is a school management mobile application built with React Native, used daily by thousands of students, parents, and teachers across India. When we took over the app, it was running on React Native 0.65, suffering from slow startup times on low-end Android devices, frequent OOM crashes, and an aging architecture that resisted incremental improvement. The goal: upgrade from 0.65 to 0.74, overhaul the lazy-loading strategy, optimize the bridge layer, add biometric authentication, and measurably improve performance on the lowest-tier devices our users actually owned.
By the end of the project, we had improved startup time by 32%, reduced crash rate by 60% on low-end devices, and cut memory usage by roughly 25% during typical usage. This article documents the specific techniques — in the order we discovered they mattered — along with real code examples.
Measuring Before Optimizing
The first mistake teams make with performance is optimizing based on intuition rather than data. Before changing a single line of code, we established baselines:
- Time to Interactive (TTI) on various device tiers (low-end, mid-range, flagship)
- JS bundle size and module breakdown
- Bridge traffic during startup
- Memory usage patterns over time
We used Flipper's performance plugin and built custom logging to capture these metrics across our test device matrix. This gave us a clear picture of where time was being spent.
The Major Upgrade: 0.65 to 0.74
Upgrading across 9 minor versions of React Native is not trivial. The ecosystem changes significantly — deprecated APIs, new architecture components, updated native dependencies. Our approach:
- Incremental upgrades — We didn't jump straight to 0.74. We went through intermediate versions (0.68, 0.71) to catch breaking changes incrementally.
- Automated regression testing — We expanded our test suite before starting to catch regressions early.
- Native module audit — Every native module was checked for compatibility. Some needed updates, some needed replacements.
The upgrade alone gave us a performance boost thanks to improvements in Hermes and the JS engine optimizations that come with newer React Native versions.
Optimizing Lazy-Loaded Modules
Lazy loading is a common technique, but doing it wrong can actually hurt performance. Our initial implementation had issues:
- Too many small lazy-loaded chunks caused waterfall loading
- Critical path modules were being lazy-loaded unnecessarily
- No preloading strategy for modules the user was likely to need next
We restructured our lazy loading with a clear strategy:
// Before: Everything lazy-loaded
const Home = React.lazy(() => import('./screens/Home'));
const Profile = React.lazy(() => import('./screens/Profile'));
const Settings = React.lazy(() => import('./screens/Settings'));
const Notifications = React.lazy(() => import('./screens/Notifications'));
// After: Critical path eager, secondary lazy with preloading
import Home from './screens/Home'; // Critical - loaded immediately
const Profile = React.lazy(() => import('./screens/Profile'));
const Settings = React.lazy(() => import('./screens/Settings'));
// Preload likely-needed screens after initial render
function preloadSecondaryScreens() {
requestIdleCallback(() => {
import('./screens/Profile');
import('./screens/Notifications');
});
}
Bridge Layer Optimization
The React Native bridge is often the bottleneck, especially on low-end devices. Every cross-bridge call has overhead. We reduced bridge traffic by:
- Batching native calls — Instead of multiple individual calls during startup, we created batch endpoints that returned all required initialization data in one round trip
- Moving animations to the native thread — Using Reanimated 2's worklets to keep animations off the JS thread entirely
- Reducing unnecessary re-renders — Memoization with
React.memoanduseMemofor components receiving data from native modules
Biometric Authentication
Adding biometric auth was a feature requirement, but it also became a performance win. By implementing fingerprint/face authentication, we could:
- Skip the full login flow for returning users, reducing time-to-content
- Cache encrypted session tokens locally, eliminating an API call on startup
- Show the home screen almost instantly for authenticated users
We used the native biometric APIs directly through a thin native module rather than relying on a heavy third-party library. This kept the overhead minimal.
Low-End Device Strategy
Not all users have flagship phones. A significant portion of our user base was on budget Android devices with 2-3GB RAM. For these devices:
- Reduced image quality dynamically based on device memory
- Disabled non-essential animations using
AccessibilityInfoand custom device-tier detection - Aggressive memory cleanup on screen unfocus to prevent OOM crashes
- Smaller list window sizes in FlatList for devices with less RAM
import { Platform, NativeModules } from 'react-native';
function getDeviceTier() {
const totalMemory = NativeModules.DeviceInfo?.totalMemory || 0;
const memoryGB = totalMemory / (1024 * 1024 * 1024);
if (memoryGB < 3) return 'low';
if (memoryGB < 6) return 'mid';
return 'high';
}
// Usage in FlatList
const tier = getDeviceTier();
const windowSize = tier === 'low' ? 5 : tier === 'mid' ? 10 : 21;
Results
After implementing these optimizations:
- Startup time improved by 32% across all device tiers
- Crash rate dropped by 60% on low-end devices
- Memory usage reduced by ~25% during typical usage
- App store rating improved as users reported snappier experience
Key Takeaways
If you're working on React Native performance, here's the distilled advice:
- Measure first — Don't optimize blindly. Profile, identify bottlenecks, then act.
- The bridge is your bottleneck — Minimize cross-bridge communication, especially during startup.
- Lazy load strategically — Not everything should be lazy-loaded. Critical path modules should be eager.
- Design for the lowest-end device — If it runs well on a budget phone, it'll fly on a flagship.
- Keep React Native updated — Each version brings performance improvements. The upgrade pain is worth it.
The techniques in this article aren't theoretical — they came from profiling real sessions on real devices with a production codebase. If your React Native app is slow to start, crashes on budget Android devices, or just feels sluggish, the same playbook applies: measure with Flipper, batch your bridge calls, get your lazy-loading strategy right, and keep updating. The results compound.
Performance is not a feature you add at the end. It's a constraint you design around from the start.