Back to Blog

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.

Viprasol Tech Team
September 20, 2026
13 min read

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 TypeWithout HermesWith HermesImprovement
Small (< 500KB bundle)1.2s0.8s33%
Medium (1-2MB bundle)2.5s1.4s44%
Large (> 3MB bundle)4.0s2.0s50%

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

MetricTargetWarningCritical
App launch (cold)< 2s2โ€“3s> 3s
Screen transition< 300ms300โ€“500ms> 500ms
List scroll FPS60fps45fps< 30fps
JS bundle size< 2MB2โ€“4MB> 4MB
Image load (cached)< 50ms50โ€“200ms> 200ms
JS thread idle> 80%60โ€“80%< 60%

See Also


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.

Mobile app development โ†’ | Performance audit โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.