React Native Performance: Hermes, FlashList, JS Bundle Optimization, and Image Caching
Optimize React Native app performance: enable Hermes engine, replace FlatList with FlashList, reduce JS bundle size, implement efficient image caching, and profile with Flipper and Perfetto.
React Native performance problems have a predictable pattern: the app works fine in development, ships with acceptable performance, and then degrades as the list gets longer, the images get more numerous, and the bundle gets heavier.
The fixes are also predictable. This post covers the high-impact interventions: Hermes for faster startup, FlashList for lists with 10,000+ items, bundle splitting for faster initial load, and image caching that eliminates the flash-of-blank-image problem.
Hermes: The First Optimization
Hermes is a JavaScript engine optimized for React Native. It compiles JS to bytecode at build time, reducing startup time by 40โ60%.
// app.json (Expo) โ enable Hermes
{
"expo": {
"jsEngine": "hermes"
}
}
// android/gradle.properties (bare React Native)
hermesEnabled=true
# ios/Podfile (bare React Native)
:hermes_enabled => true
Startup improvement with Hermes:
| App Type | Without Hermes | With Hermes | Improvement |
|---|---|---|---|
| Small (< 500KB bundle) | 1.2s | 0.8s | 33% |
| Medium (1-2MB bundle) | 2.5s | 1.4s | 44% |
| Large (> 3MB bundle) | 4.0s | 2.0s | 50% |
Hermes is the default in React Native 0.70+. If you're on an older version, upgrading is the highest-ROI performance change you can make.
FlashList: Replacing FlatList
FlatList re-renders items as they scroll, causing the well-known "blank cell" jank. FlashList from Shopify recycles cells like native UITableView/RecyclerView:
npm install @shopify/flash-list
// โ FlatList โ blanks and jank above ~500 items
import { FlatList } from "react-native";
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ProductCard product={item} />}
// These optimizations don't solve the fundamental recycling problem
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews={true}
/>
// โ
FlashList โ smooth scrolling to 100,000+ items
import { FlashList } from "@shopify/flash-list";
<FlashList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ProductCard product={item} />}
// Required: estimate item height for accurate recycling
estimatedItemSize={80}
// Optional: override for variable-height items
getItemType={(item) => item.type} // "header" | "product" | "ad"
// Optional: override estimated size per item type
overrideItemLayout={(layout, item) => {
layout.size = item.type === "header" ? 120 : 80;
}}
// FlashList handles this internally โ no need for these:
// maxToRenderPerBatch, windowSize, removeClippedSubviews
/>
FlashList with Variable Heights
// When items have truly variable heights, measure them:
import { FlashList, ListRenderItemInfo } from "@shopify/flash-list";
import { useRef, useCallback } from "react";
interface Item {
id: string;
type: "post" | "ad" | "header";
content: string;
}
export function InfiniteList({ items }: { items: Item[] }) {
const heightCache = useRef(new Map<string, number>());
const getItemType = useCallback((item: Item) => item.type, []);
const overrideItemLayout = useCallback(
(layout: { size: number }, item: Item) => {
const cached = heightCache.current.get(item.id);
if (cached) {
layout.size = cached;
}
// If not cached, use estimatedItemSize โ FlashList adjusts on measure
},
[]
);
const renderItem = useCallback(
({ item }: ListRenderItemInfo<Item>) => (
<ItemView
item={item}
onLayout={(height) => {
heightCache.current.set(item.id, height);
}}
/>
),
[]
);
return (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemType={getItemType}
overrideItemLayout={overrideItemLayout}
estimatedItemSize={100}
// Optimize for bidirectional scrolling
estimatedListSize={{ height: 800, width: 390 }}
/>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
JS Bundle Optimization
Analyze Bundle Size
# Expo
npx expo export --platform ios
npx expo export --platform android
# Analyze with source-map-explorer
npm install --save-dev source-map-explorer
npx source-map-explorer dist/bundles/ios/main.jsbundle \
dist/bundles/ios/main.jsbundle.map
# Or use react-native-bundle-visualizer
npx react-native-bundle-visualizer
Common Bundle Bloat and Fixes
// โ Importing entire date library (moment: 67KB, luxon: 70KB)
import moment from "moment";
const formatted = moment(date).format("MMM D, YYYY");
// โ
date-fns tree-shaking (only format function: ~3KB)
import { format } from "date-fns";
const formatted = format(new Date(date), "MMM d, yyyy");
// โ
Or Intl API (zero bundle cost โ native)
const formatted = new Intl.DateTimeFormat("en-US", {
month: "short", day: "numeric", year: "numeric"
}).format(new Date(date));
// โ Entire icon set (react-native-vector-icons: 2-15MB)
import Icon from "react-native-vector-icons/MaterialIcons";
// โ
Only the icons you use (react-native-svg + custom set)
import HomeSvg from "@/assets/icons/home.svg";
import SearchSvg from "@/assets/icons/search.svg";
// โ Lodash full import
import _ from "lodash";
// โ
Individual functions
import debounce from "lodash/debounce";
import throttle from "lodash/throttle";
Lazy Loading Screens
// src/navigation/AppNavigator.tsx
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { lazy, Suspense } from "react";
// โ Eager imports โ all screens in initial bundle
import HomeScreen from "@/screens/HomeScreen";
import ProfileScreen from "@/screens/ProfileScreen";
import SettingsScreen from "@/screens/SettingsScreen";
import AnalyticsScreen from "@/screens/AnalyticsScreen";
// โ
Lazy imports โ screen code loaded on first navigation
// Note: React Native's Metro bundler supports this in RN 0.72+
const HomeScreen = lazy(() => import("@/screens/HomeScreen"));
const ProfileScreen = lazy(() => import("@/screens/ProfileScreen"));
const SettingsScreen = lazy(() => import("@/screens/SettingsScreen"));
const AnalyticsScreen = lazy(() => import("@/screens/AnalyticsScreen"));
const Stack = createNativeStackNavigator();
export function AppNavigator() {
return (
<Stack.Navigator>
<Stack.Screen
name="Home"
component={() => (
<Suspense fallback={<ScreenSkeleton />}>
<HomeScreen />
</Suspense>
)}
/>
{/* ... */}
</Stack.Navigator>
);
}
Image Caching
The default React Native Image component fetches images on every render. Use expo-image (or react-native-fast-image for bare RN) for disk caching:
npx expo install expo-image
// โ
expo-image: disk cache, blurhash placeholders, transitions
import { Image } from "expo-image";
// Basic cached image
<Image
source={{ uri: "https://cdn.viprasol.com/images/product-123.jpg" }}
style={{ width: 200, height: 200 }}
// Cache to disk for 1 week
cachePolicy="disk"
// Blurhash while loading (generate server-side with @woltapp/blurhash)
placeholder={{ blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4" }}
// Smooth fade-in transition
transition={300}
contentFit="cover"
/>
// High-priority hero image (skip queue)
<Image
source={{ uri: heroImageUrl }}
style={styles.hero}
priority="high"
cachePolicy="memory-disk"
placeholder={{ blurhash: product.blurhash }}
/>
Prefetching Images
import { Image } from "expo-image";
// Prefetch images that are likely to be viewed soon
export function usePrefetchNextPage(items: Product[]) {
useEffect(() => {
// Prefetch images for the next 5 items
const imagesToPrefetch = items
.slice(currentIndex, currentIndex + 5)
.map((item) => item.imageUrl)
.filter(Boolean);
Image.prefetch(imagesToPrefetch);
}, [items, currentIndex]);
}
Generating Blurhash Server-Side
// src/services/image.service.ts (server)
import { encode } from "blurhash";
import sharp from "sharp";
export async function generateBlurhash(imageUrl: string): Promise<string> {
// Download and resize to tiny version for hashing
const response = await fetch(imageUrl);
const buffer = await response.arrayBuffer();
const { data, info } = await sharp(Buffer.from(buffer))
.resize(32, 32, { fit: "inside" }) // Tiny for fast hash
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const blurhash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // X components (more = more detail)
3 // Y components
);
return blurhash; // e.g., "L6PZfSi_.AyE_3t7t7R**0o#DgR4"
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Memoization Patterns
// React Native re-renders are expensive โ memoize aggressively
import { memo, useCallback, useMemo } from "react";
import { StyleSheet } from "react-native";
// โ
Memoize list items โ prevents re-render on parent state change
const ProductCard = memo(function ProductCard({
product,
onPress,
}: {
product: Product;
onPress: (id: string) => void;
}) {
// useCallback in memo component prevents unnecessary child re-renders
const handlePress = useCallback(() => {
onPress(product.id);
}, [product.id, onPress]);
return (
<Pressable style={styles.card} onPress={handlePress}>
{/* ... */}
</Pressable>
);
});
// โ
StyleSheet.create caches styles โ create outside component
const styles = StyleSheet.create({
card: {
backgroundColor: "#fff",
borderRadius: 8,
padding: 12,
marginBottom: 8,
},
});
// โ Inline styles create new objects on every render
// <View style={{ backgroundColor: "#fff", borderRadius: 8 }}>
// โ
useMemo for expensive computations
function ProductList({ products, searchQuery }: Props) {
const filtered = useMemo(
() =>
products.filter((p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
),
[products, searchQuery]
);
// Stable callback reference for FlashList renderItem
const renderItem = useCallback(
({ item }: { item: Product }) => (
<ProductCard product={item} onPress={handlePress} />
),
[handlePress]
);
return (
<FlashList
data={filtered}
renderItem={renderItem}
keyExtractor={keyExtractor} // Define outside component too
estimatedItemSize={80}
/>
);
}
// Define outside component โ stable reference, not recreated per render
const keyExtractor = (item: Product) => item.id;
Profiling with Flipper
// src/utils/performance-marks.ts
// Custom performance marks for Flipper Performance Monitor
import { PerformanceObserver, performance } from "react-native";
export function measureRender(componentName: string) {
const startMark = `${componentName}-render-start`;
const endMark = `${componentName}-render-end`;
const measureName = `${componentName}-render`;
performance.mark(startMark);
return () => {
performance.mark(endMark);
performance.measure(measureName, startMark, endMark);
const entries = performance.getEntriesByName(measureName);
const duration = entries[entries.length - 1]?.duration ?? 0;
if (duration > 16) {
// Longer than one frame (60fps = 16.7ms)
console.warn(`Slow render: ${componentName} took ${duration.toFixed(1)}ms`);
}
};
}
// Usage with useEffect
function HeavyComponent() {
useEffect(() => {
const endMeasure = measureRender("HeavyComponent");
return endMeasure;
});
// ...
}
Performance Budget Reference
| Metric | Target | Warning | Critical |
|---|---|---|---|
| App launch (cold) | < 2s | 2โ3s | > 3s |
| Screen transition | < 300ms | 300โ500ms | > 500ms |
| List scroll FPS | 60fps | 45fps | < 30fps |
| JS bundle size | < 2MB | 2โ4MB | > 4MB |
| Image load (cached) | < 50ms | 50โ200ms | > 200ms |
| JS thread idle | > 80% | 60โ80% | < 60% |
See Also
- React Native Navigation Patterns โ navigation performance
- React Native Testing Strategies โ testing performance regressions
- React Native Offline-First Architecture โ offline + sync
- Mobile Payment Integration โ in-app payment flows
Working With Viprasol
React Native performance issues compound over time โ a smooth app at launch becomes sluggish at 50,000 items and 3MB of feature additions. Our mobile engineers conduct performance audits, implement FlashList migrations, optimize bundle sizes, and establish performance budgets that prevent regression.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.