Typescript
debugging
performance
optimization
react native
Profiling
Hermes
Flipper
Your app works perfectly on your MacBook with an iPhone 14 Pro connected. But in production, users complain about lag, choppy scrolling, and screens that take seconds to load. The problem with performance is that it's invisible until it becomes a serious issue, and by then you already have one-star reviews and users abandoning your app.
Performance in React Native isn't about applying every optimization you know and hoping for the best. It's about scientifically measuring where the real problem is, applying the specific fix, and validating that it worked. Without profiling tools, you're optimizing blindly. With them, you turn performance debugging into a systematic and predictable process.
Hermes is the JavaScript engine designed by Meta specifically for React Native. Its integrated profiler shows you exactly which functions in your code consume CPU time, with millisecond precision.
First verify that Hermes is enabled. In recent React Native CLI projects it comes activated by default, but always confirm. For Android, check your android/app/build.gradle file and look for enableHermes: true. For iOS, in your Podfile you should have hermes_enabled => true in the use_react_native configuration.
You can verify at runtime if Hermes is active with this simple code in any component.
1const isHermesEnabled = (): boolean => { 2 return !!global.HermesInternal; 3}; 4 5// Use in development to confirm 6useEffect(() => { 7 if (__DEV__) { 8 console.log('Hermes enabled:', isHermesEnabled()); 9 } 10}, []);
If you see true, you're ready. If you see false, you need to enable Hermes in the configuration and do a complete app rebuild.
There are two ways to capture Hermes profiles. The first is manual using the development menu. Shake your device or press Cmd+D on iOS, Cmd+M on Android. You'll see options for "Enable Sampling Profiler". Activate it, use your app reproducing the flow you want to analyze for 10-30 seconds, then return to the menu and select "Disable Sampling Profiler". This generates a file you can download.
The second way is programmatic, useful when you want to capture profiles of specific flows automatically.
1import { Platform } from 'react-native'; 2 3interface ProfileCapture { 4 start: () => void; 5 stop: () => Promise<string | null>; 6} 7 8const useHermesProfiler = (): ProfileCapture => { 9 const start = () => { 10 if (!__DEV__ || !global.HermesInternal) return; 11 12 console.log('Starting Hermes profiler...'); 13 }; 14 15 const stop = async (): Promise<string | null> => { 16 if (!global.HermesInternal) { 17 console.warn('Hermes not available'); 18 return null; 19 } 20 21 try { 22 const path = await global.HermesInternal.getProfile?.(); 23 console.log('Profile saved at:', path); 24 return path; 25 } catch (error) { 26 console.error('Failed to capture profile:', error); 27 return null; 28 } 29 }; 30 31 return { start, stop }; 32}; 33 34// Use in a debug screen 35const DebugScreen: React.FC = () => { 36 const profiler = useHermesProfiler(); 37 38 return ( 39 <View> 40 <Button title="Start Profile" onPress={profiler.start} /> 41 <Button title="Stop Profile" onPress={profiler.stop} /> 42 </View> 43 ); 44};
On Android, the file is saved in /data/data/com.yourapp/cache/. Extract it with adb pull /data/data/com.yourapp/cache/profile.cpuprofile. On iOS, Xcode allows you to download files from the app container.
The .cpuprofile file opens in Chrome DevTools. Go to chrome://inspect, then in the Profiler tab load the file. Chrome will show you three different views of the same profile.
The Chart view is a flame graph where each horizontal bar represents a function. The width of the bar is proportional to the time it consumed. The widest bars are your primary optimization candidates. You can click on any bar to see the exact source code and complete call stack.
The Heavy view groups all function calls by name, showing you the most expensive ones first regardless of where they were called from. This is perfect for finding problematic functions called from multiple places. If you see formatPrice appearing consuming 600ms total, you know you need to optimize it or cache its results.
The Tree view shows the complete hierarchical call stack. Useful when you want to understand the context of why a function is slow. You can see the entire chain of calls that led to executing expensive code.

Flipper is much more than a debugger, it's an ecosystem of plugins that give you complete visibility into your app's internal behavior in real time.

In recent React Native CLI projects, Flipper should already be configured. But to access all plugins you need some additional steps.
Download Flipper Desktop from the official site. Once installed, make sure your project has the necessary dependencies.
1npm install --save-dev react-native-flipper
For iOS, your Podfile should include Flipper configuration. It should look similar to this, although the specific version may vary.
1use_flipper!() 2 3post_install do |installer| 4 flipper_post_install(installer) 5end
Run pod install in the ios folder. For Android, integration should be automatic in recent versions.
Now start your app in debug mode. Open Flipper Desktop and you should see your app appear in the list of connected devices. If it doesn't appear, verify that the device and your computer are on the same network, or use USB connection with adb reverse configured.
The React plugin in Flipper is your main weapon against components that render unnecessarily. When your app feels slow but you don't see expensive functions in Hermes, the problem is probably excessive re-renders.
Connect Flipper and select the React DevTools plugin. You'll see your complete component tree. Now activate the "Highlight Updates" option in the plugin settings. This is the killer feature.
Use your app normally. You'll see that components that render briefly light up in different colors on your device. If the entire screen lights up when you change a text input, you have a serious architectural problem. Probably the state is too high up in the tree and causes re-render cascades.
1// Common problem identified with Highlight Updates 2interface UserProfileProps { 3 userId: string; 4} 5 6const UserProfile: React.FC<UserProfileProps> = ({ userId }) => { 7 const [searchQuery, setSearchQuery] = useState(''); 8 const [userPosts, setUserPosts] = useState<Post[]>([]); 9 const [userData, setUserData] = useState<User | null>(null); 10 11 return ( 12 <View> 13 <Header /> 14 <SearchBar value={searchQuery} onChange={setSearchQuery} /> 15 <UserInfo user={userData} /> 16 <PostList posts={userPosts} /> 17 <Footer /> 18 </View> 19 ); 20};
With Highlight Updates active, typing in the SearchBar lights up Header, UserInfo, PostList and Footer unnecessarily. All those components re-render even though they don't depend on searchQuery. The solution is to isolate state as close as possible to where it's used.
1const UserProfile: React.FC<UserProfileProps> = ({ userId }) => { 2 const [userPosts, setUserPosts] = useState<Post[]>([]); 3 const [userData, setUserData] = useState<User | null>(null); 4 5 return ( 6 <View> 7 <Header /> 8 <SearchSection /> 9 <UserInfo user={userData} /> 10 <PostList posts={userPosts} /> 11 <Footer /> 12 </View> 13 ); 14}; 15 16const SearchSection: React.FC = () => { 17 const [searchQuery, setSearchQuery] = useState(''); 18 19 return ( 20 <SearchBar value={searchQuery} onChange={setSearchQuery} /> 21 ); 22};
Now only SearchSection lights up when you type. The rest of the screen remains static. The plugin also shows a render counter per component. Click on any component in the tree to see how many times it has rendered. If a simple component rendered 200 times in 30 seconds, you found your problem.
The Performance Monitor shows two real-time metrics you should constantly watch during development.
JavaScript FPS indicates how well your React code is performing. It should stay close to 60 (or 120 on high refresh rate devices). If it drops to 40-50 during normal interactions, your code is doing too much work. Typical causes are expensive calculations in render, functions that aren't memoized, or Context propagating changes to too many components.
UI FPS indicates how well native rendering is performing. It should also be at 60. If it drops but JS FPS is fine, the problem is in poorly implemented animations, complex layouts with many nested views, or unoptimized images. The key is observing exactly when FPS drops. Reproduce specific actions while watching the monitor. Scroll through a list, navigate between screens, open an animated modal, load images. When you see the FPS drop, you know exactly which action causes the problem.
Users don't perceive your app as slow because your JavaScript is slow. They perceive it as slow because they spend seconds looking at spinners waiting for server data. The Network Inspector shows every HTTP request with detailed timing. Click on any request to see a complete breakdown of time in specific phases.
DNS lookup shows how long it took to resolve the domain to an IP. Connection shows the time to establish the TCP connection. If you use HTTPS, you'll also see TLS handshake. TTFB is Time To First Byte, how long the server took to start sending the response. Download is how long it took to transfer all data.
If TTFB is high, the problem is on the server, not in your app. If Download is slow but TTFB is fast, the problem is the response size. If you see multiple requests to the same endpoint in a few seconds, you need deduplication.
1// Problem visible in Network Inspector 2const ProductScreen: React.FC<{ productId: string }> = ({ productId }) => { 3 const [product, setProduct] = useState<Product | null>(null); 4 const [reviews, setReviews] = useState<Review[]>([]); 5 const [relatedProducts, setRelatedProducts] = useState<Product[]>([]); 6 7 useEffect(() => { 8 fetch(`http://example/api/products/${productId}`).then(r => r.json()).then(setProduct); 9 fetch(`http://example/api/products/${productId}/reviews`).then(r => r.json()).then(setReviews); 10 fetch(`http://example/api/products/${productId}/related`).then(r => r.json()).then(setRelatedProducts); 11 }, [productId]); 12 13 if (!product) return <Loading />; 14 15 return <ProductDetails product={product} reviews={reviews} related={relatedProducts} />; 16};
In Flipper you'll see three sequential requests. The total can be 2-3 seconds. But these three requests can be made in parallel, or even better, the server can have an endpoint that returns everything together.
Images are the number one cause of memory crashes in React Native apps. The Images plugin shows you all images currently loaded in RAM, their size in memory versus screen size, and allows you to inspect them visually.
A 3000x2000 uncompressed image consumes approximately 24MB of RAM. If you have 20 of those in a gallery, that's 480MB just in images. On devices with 2-3GB total RAM, your app will inevitably crash.
The solution has two parts. First, your server should offer multiple sizes of each image. Second, your app should request the appropriate size.
1import FastImage from 'react-native-fast-image'; 2import { Dimensions, PixelRatio } from 'react-native'; 3 4interface OptimizedImageProps { 5 imageId: string; 6 width: number; 7 height: number; 8} 9 10const OptimizedImage: React.FC<OptimizedImageProps> = ({ 11 imageId, 12 width, 13 height 14}) => { 15 const pixelRatio = PixelRatio.get(); 16 const optimalWidth = Math.ceil(width * pixelRatio); 17 const optimalHeight = Math.ceil(height * pixelRatio); 18 19 const imageUrl = `https://example.com/images/${imageId}?w=${optimalWidth}&h=${optimalHeight}&q=85&fm=webp`; 20 21 return ( 22 <FastImage 23 source={{ 24 uri: imageUrl, 25 priority: FastImage.priority.normal, 26 cache: FastImage.cacheControl.immutable, 27 }} 28 style={{ width, height }} 29 resizeMode={FastImage.resizeMode.cover} 30 /> 31 ); 32};
In Flipper Images you'll see that now each image consumes only 200-500KB instead of 24MB. The plugin also alerts you if there are image memory leaks. If you navigate away from a screen but its images remain in memory, you have hanging references.
Systrace is an Android tool that captures activity from ALL system threads, not just your app. It shows you exactly what each thread is doing microsecond by microsecond.
Systrace generates large HTML files with microscopic information. The key is to capture only the problematic period.
First make sure you have Android SDK configured and adb working. Then start the capture before reproducing the problem.
1adb shell atrace --async_start -b 20000 -a com.yourapp sched gfx view wm am input res dalvik 2 3# Reproduce the problem in your app for 5-10 seconds 4 5adb shell atrace --async_stop > trace.html
The -b 20000 parameter is the buffer size in KB. For longer captures, increase this number. The tags after -a determine what information to capture. sched is thread scheduling, gfx is rendering, view is the view system, etc.
Open the HTML file in Chrome. You'll see an interface with complete timeline. Each row is a different thread. The colored bars are work being executed. White spaces are idle time or waiting.
Look for the SurfaceFlinger row near the top. This is the final frame compositor. Each frame should be a green bar of approximately 16.67ms for 60 FPS. Red or orange bars are frames that took longer than 16.67ms, causing visible stuttering.
Click on a problematic red bar. The bottom panel shows complete details including the call stack that caused the delay. It might show measure or layout, indicating that calculating view positions was expensive. Or it might show draw, indicating that drawing pixels took too long.
With the tools understood, let's see specific strategies to solve the most common problems you'll find in profiling.
React Context is convenient but dangerous. When a Context value changes, all components that consume it re-render, regardless of whether they use the part that changed.
1type AppContextValue = { 2 user: User | null; 3 theme: 'light' | 'dark'; 4 settings: Settings; 5}; 6 7const AppContext = createContext<AppContextValue | null>(null); 8 9const App: React.FC = () => { 10 const [user, setUser] = useState<User | null>(null); 11 const [theme, setTheme] = useState<'light' | 'dark'>('light'); 12 const [settings, setSettings] = useState<Settings>(defaultSettings); 13 14 const value = { user, theme, settings }; 15 16 return ( 17 <AppContext.Provider value={value}> 18 <Navigation /> 19 </AppContext.Provider> 20 ); 21};
The problem is twofold. First, the object literal creates a new reference on every render, causing all consumers to re-render. Second, changing theme causes re-render of components that only use user. The solution is to separate Contexts by domain and memoize the value.
1const UserContext = createContext<User | null>(null); 2const ThemeContext = createContext<'light' | 'dark'>('light'); 3const SettingsContext = createContext<Settings>(defaultSettings); 4 5const App: React.FC = () => { 6 const [user, setUser] = useState<User | null>(null); 7 const [theme, setTheme] = useState<'light' | 'dark'>('light'); 8 const [settings, setSettings] = useState<Settings>(defaultSettings); 9 10 return ( 11 <UserContext.Provider value={user}> 12 <ThemeContext.Provider value={theme}> 13 <SettingsContext.Provider value={settings}> 14 <Navigation /> 15 </SettingsContext.Provider> 16 </ThemeContext.Provider> 17 </UserContext.Provider> 18 ); 19};
Now changing theme only affects components that consume ThemeContext. In Flipper React DevTools with Highlight Updates, you'll see that only visual UI components light up, not data components.
Not all events need to be processed immediately. Search inputs especially should be debounced.
1const useDebounce = <T,>(value: T, delay: number): T => { 2 const [debouncedValue, setDebouncedValue] = useState<T>(value); 3 4 useEffect(() => { 5 const handler = setTimeout(() => { 6 setDebouncedValue(value); 7 }, delay); 8 9 return () => { 10 clearTimeout(handler); 11 }; 12 }, [value, delay]); 13 14 return debouncedValue; 15}; 16 17const SearchScreen: React.FC = () => { 18 const [query, setQuery] = useState(''); 19 const debouncedQuery = useDebounce(query, 500); 20 21 useEffect(() => { 22 if (debouncedQuery) { 23 performSearch(debouncedQuery); 24 } 25 }, [debouncedQuery]); 26 27 return ( 28 <TextInput 29 value={query} 30 onChangeText={setQuery} 31 placeholder="Search..." 32 /> 33 ); 34};
In Network Inspector you'll see that typing "react native" generates 1 request after half a second of the user stopping, not 12 requests while typing.
For apps with many images you need more sophisticated caching than just FastImage default.
1import RNFS from 'react-native-fs'; 2 3class ImageCache { 4 private memoryCache = new Map<string, string>(); 5 private diskPath = `${RNFS.CachesDirectoryPath}/images/`; 6 7 async getImage(url: string): Promise<string> { 8 const key = this.hashUrl(url); 9 10 if (this.memoryCache.has(key)) { 11 return this.memoryCache.get(key)!; 12 } 13 14 const diskFile = `${this.diskPath}${key}.jpg`; 15 const exists = await RNFS.exists(diskFile); 16 17 if (exists) { 18 this.memoryCache.set(key, diskFile); 19 return diskFile; 20 } 21 22 return this.downloadAndCache(url, key, diskFile); 23 } 24 25 private async downloadAndCache(url: string, key: string, path: string): Promise<string> { 26 await RNFS.downloadFile({ fromUrl: url, toFile: path }).promise; 27 this.memoryCache.set(key, path); 28 return path; 29 } 30 31 private hashUrl(url: string): string { 32 return url.split('').reduce((hash, char) => { 33 return ((hash << 5) - hash) + char.charCodeAt(0); 34 }, 0).toString(); 35 } 36} 37 38const imageCache = new ImageCache();
In Flipper Images you'll see that cached images load instantly and don't appear in Network Inspector.
Before launching to production, run this checklist. Capture a Hermes profile of the main flows. No function should consume more than 10% of total time. Use Flipper Performance Monitor during 5 minutes of intensive use. FPS should consistently stay at 55 or higher. Review Flipper Images during normal use. No image should be more than 5x its visual size.
Capture a 30-second Android Systrace. There should be no more than 5-10 red frames during normal use. Review Network Inspector. Average request time should be less than 500ms for the 90th percentile. All of this should be done on production builds, not development. Dev builds have overhead that hides real problems.
Performance profiling in React Native is systematic engineering, not dark art. Hermes tells you which functions are slow. Flipper gives you visibility into renders, network, memory and images. Systrace reveals threading and rendering problems that other tools don't see.
The key is to measure before optimizing. Without a baseline you don't know if you improved. Without profiling you optimize the wrong thing. Without validation you don't know if the problem was solved. With these tools and this process, optimization becomes predictable and effective.