Skip to content

Latest commit

 

History

History
696 lines (520 loc) · 21.2 KB

File metadata and controls

696 lines (520 loc) · 21.2 KB

Performance Optimization in React Native

Table of Contents


Why Performance Matters in React Native

React Native runs JavaScript on a separate thread from the native UI thread. When the JS thread is blocked — by heavy computation, excessive re-renders, or large list rendering — users experience jank: dropped frames, delayed touch responses, and stuttering scroll.

Performance optimization in React Native targets three layers:

Layer What to Optimize Common Tools
JS Thread Re-renders, memoization, list virtualization React DevTools, Hermes
UI Thread Layout, animations, native modules Flipper, Systrace
Bridge / JSI Serialization overhead, native calls New Architecture, TurboModules

A 60 FPS target means each frame has 16.67 ms to complete. Missing this budget causes visible stutter.


FlatList Optimization

FlatList virtualizes long lists by rendering only visible items plus a small buffer. Without proper configuration, it re-renders unnecessarily and measures items on every scroll frame.

Essential FlatList Props

import React, { useCallback, memo } from 'react';
import {
  FlatList,
  ListRenderItem,
  StyleSheet,
  Text,
  View,
} from 'react-native';

interface Product {
  id: string;
  name: string;
  price: number;
}

const ITEM_HEIGHT = 72;

// Memoized row component prevents re-renders when parent state changes
const ProductRow = memo(({ item }: { item: Product }) => (
  <View style={styles.row}>
    <Text style={styles.name}>{item.name}</Text>
    <Text style={styles.price}>${item.price.toFixed(2)}</Text>
  </View>
));

export function ProductList({ products }: { products: Product[] }) {
  const keyExtractor = useCallback((item: Product) => item.id, []);

  const renderItem: ListRenderItem<Product> = useCallback(
    ({ item }) => <ProductRow item={item} />,
    [],
  );

  const getItemLayout = useCallback(
    (_: ArrayLike<Product> | null | undefined, index: number) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index,
    }),
    [],
  );

  return (
    <FlatList
      data={products}
      keyExtractor={keyExtractor}
      renderItem={renderItem}
      getItemLayout={getItemLayout}
      initialNumToRender={10}
      maxToRenderPerBatch={10}
      windowSize={5}
      removeClippedSubviews={true}
      updateCellsBatchingPeriod={50}
    />
  );
}

const styles = StyleSheet.create({
  row: {
    height: ITEM_HEIGHT,
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingHorizontal: 16,
    alignItems: 'center',
  },
  name: { fontSize: 16 },
  price: { fontSize: 14, color: '#666' },
});

Key Optimization Techniques

Prop / Technique Purpose
keyExtractor Stable unique keys prevent full list remounts
getItemLayout Skips async layout measurement for fixed-height rows
memo on row component Prevents row re-render when unrelated state changes
windowSize Controls how many screen-heights of items stay mounted
removeClippedSubviews Unmounts off-screen views (Android benefit is larger)

When NOT to Use FlatList

For fewer than ~20 static items, a simple ScrollView with mapped children is fine. For infinitely scrolling feeds with variable heights, consider @shopify/flash-list which recycles views more aggressively.


Memoization with useMemo and useCallback

useMemo caches computed values; useCallback caches function references. Both prevent child components wrapped in React.memo from re-rendering due to new object/function references on every parent render.

import React, { useCallback, useMemo, useState } from 'react';
import { Button, FlatList, Text, View } from 'react-native';

interface Order {
  id: string;
  total: number;
  status: 'pending' | 'completed';
}

interface OrderListProps {
  orders: Order[];
  onOrderPress: (id: string) => void;
}

const OrderList = React.memo(({ orders, onOrderPress }: OrderListProps) => (
  <FlatList
    data={orders}
    keyExtractor={(item) => item.id}
    renderItem={({ item }) => (
      <Button title={`Order ${item.id}`} onPress={() => onOrderPress(item.id)} />
    )}
  />
));

export function OrdersScreen({ orders }: { orders: Order[] }) {
  const [filter, setFilter] = useState<'all' | 'pending'>('all');

  // useMemo: expensive filtering runs only when orders or filter changes
  const filteredOrders = useMemo(
    () =>
      filter === 'all'
        ? orders
        : orders.filter((o) => o.status === 'pending'),
    [orders, filter],
  );

  // useCallback: stable reference so OrderList memoization works
  const handleOrderPress = useCallback((id: string) => {
    console.log('Selected order:', id);
  }, []);

  const totalRevenue = useMemo(
    () => filteredOrders.reduce((sum, o) => sum + o.total, 0),
    [filteredOrders],
  );

  return (
    <View>
      <Text>Revenue: ${totalRevenue.toFixed(2)}</Text>
      <Button title="Show Pending" onPress={() => setFilter('pending')} />
      <OrderList orders={filteredOrders} onOrderPress={handleOrderPress} />
    </View>
  );
}

When Memoization Helps vs Hurts

Use memoization when:

  • Passing callbacks to memoized children
  • Computing expensive derived data (sorting, filtering large arrays)
  • Preventing unnecessary effect re-runs

Avoid over-memoization when:

  • Values are cheap to compute
  • Components always re-render anyway (parent not memoized)
  • Premature optimization adds complexity without measurable gain

React.memo and Component Optimization

React.memo performs a shallow prop comparison before re-rendering. Combine it with stable props for maximum benefit.

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

interface AvatarProps {
  name: string;
  size: number;
  isOnline: boolean;
}

// Custom comparison for complex props
const Avatar = React.memo(
  ({ name, size, isOnline }: AvatarProps) => (
    <View style={[styles.avatar, { width: size, height: size }]}>
      <Text>{name.charAt(0).toUpperCase()}</Text>
      {isOnline && <View style={styles.onlineDot} />}
    </View>
  ),
  (prev, next) =>
    prev.name === next.name &&
    prev.size === next.size &&
    prev.isOnline === next.isOnline,
);

const styles = StyleSheet.create({
  avatar: {
    borderRadius: 999,
    backgroundColor: '#ddd',
    alignItems: 'center',
    justifyContent: 'center',
  },
  onlineDot: {
    position: 'absolute',
    bottom: 0,
    right: 0,
    width: 10,
    height: 10,
    borderRadius: 5,
    backgroundColor: '#22c55e',
  },
});

export default Avatar;

Hermes JavaScript Engine

Hermes is a JavaScript engine optimized for React Native on mobile. It improves startup time, memory usage, and TTI (Time to Interactive).

Hermes Benefits

Benefit Explanation
Precompiled bytecode No JIT warmup; faster cold start
Smaller memory footprint Better on low-end Android devices
Built-in Intl support Internationalization without polyfills

Enabling Hermes

Hermes is enabled by default in React Native 0.70+. Verify in android/gradle.properties:

hermesEnabled=true

For iOS, check ios/Podfile:

use_react_native!(
  :hermes_enabled => true,
)

Verifying Hermes at Runtime

const isHermes = () => !!(global as typeof global & { HermesInternal?: unknown }).HermesInternal;

console.log('Hermes enabled:', isHermes());

Hermes and Debugging

Use Chrome DevTools via Metro or Flipper for debugging. Hermes supports source maps for readable stack traces in production crash reports.


Profiling with Flipper and React DevTools

Flipper Setup

Flipper provides layout inspection, network monitoring, database plugins, and the React DevTools and Performance plugins.

# Install Flipper desktop app from https://fbflipper.com
# React Native 0.62+ includes Flipper integration by default
npx react-native start
# Run app on device/simulator — Flipper auto-detects

What to Look For When Profiling

  1. JS thread frame drops during scroll — optimize list rendering
  2. Long tasks on mount — defer non-critical work, use lazy loading
  3. Excessive bridge traffic — batch native calls, adopt New Architecture
  4. Memory growth — check for listener leaks, unbounded caches

React DevTools Profiler Workflow

// Wrap suspicious subtree to isolate in Profiler
import { Profiler, ProfilerOnRenderCallback } from 'react';

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
) => {
  if (actualDuration > 16) {
    console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms (${phase})`);
  }
};

export function ProfiledScreen({ children }: { children: React.ReactNode }) {
  return (
    <Profiler id="HomeScreen" onRender={onRender}>
      {children}
    </Profiler>
  );
}

Performance Monitoring in Production

Use libraries like @shopify/react-native-performance or custom analytics to track screen load times and frame rates in release builds.


Image Optimization

Images are a common source of memory pressure and slow loads.

Best Practices

import React from 'react';
import { Dimensions, Image, StyleSheet, View } from 'react-native';

const { width: SCREEN_WIDTH } = Dimensions.get('window');
const THUMB_SIZE = SCREEN_WIDTH / 3;

interface ThumbnailProps {
  uri: string;
}

export function OptimizedThumbnail({ uri }: ThumbnailProps) {
  return (
    <View style={styles.container}>
      <Image
        source={{ uri }}
        style={styles.image}
        resizeMode="cover"
        // Request appropriately sized image from CDN when possible
        // e.g., uri + '?w=200&h=200'
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    width: THUMB_SIZE,
    height: THUMB_SIZE,
  },
  image: {
    width: '100%',
    height: '100%',
  },
});

Image Optimization Checklist

Technique Impact
Serve WebP/AVIF from CDN Smaller downloads
Request correct dimensions Avoid decoding 4K into 100px thumbnail
Use react-native-fast-image Disk/memory caching, priority loading
Prefetch critical images Image.prefetch(url) before navigation
Use local assets for icons Bundled, no network latency
Lazy load off-screen images Intersection-based loading in lists

Fast Image Example

import FastImage from 'react-native-fast-image';

<FastImage
  source={{
    uri: 'https://cdn.example.com/photo.webp',
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }}
  style={{ width: 200, height: 200 }}
  resizeMode={FastImage.resizeMode.cover}
/>

Bundle Size Reduction

Smaller JS bundles mean faster startup and less memory.

Strategies

// 1. Lazy load screens with React.lazy (requires Suspense boundary)
import React, { Suspense, lazy } from 'react';
import { ActivityIndicator, View } from 'react-native';

const SettingsScreen = lazy(() => import('./screens/SettingsScreen'));

export function AppNavigator() {
  return (
    <Suspense
      fallback={
        <View style={{ flex: 1, justifyContent: 'center' }}>
          <ActivityIndicator />
        </View>
      }
    >
      <SettingsScreen />
    </Suspense>
  );
}
// 2. metro.config.js — enable inline requires for deferred module loading
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};

Bundle Analysis

# Generate bundle and analyze
npx react-native bundle \
  --platform android \
  --dev false \
  --entry-file index.js \
  --bundle-output /tmp/android.bundle

# Use source-map-explorer or react-native-bundle-visualizer
npx react-native-bundle-visualizer

Bundle Size Checklist

  • Remove unused dependencies (depcheck, npm ls)
  • Replace heavy libraries (moment.js → date-fns or dayjs)
  • Tree-shake lodash: import debounce from 'lodash/debounce'
  • Split features by route with dynamic imports
  • Enable ProGuard/R8 on Android for native code shrinking
  • Audit native modules — each adds binary size

Additional Performance Tips

// Avoid inline styles and anonymous functions in hot paths
// BAD — new object every render
<View style={{ padding: 16 }} />

// GOOD — StyleSheet.create is optimized
const styles = StyleSheet.create({ container: { padding: 16 } });
<View style={styles.container} />

// Defer heavy work until after interactions
import { InteractionManager } from 'react-native';

InteractionManager.runAfterInteractions(() => {
  loadHeavyAnalyticsData();
});

// Use native driver for animations (runs on UI thread)
Animated.timing(value, {
  toValue: 1,
  duration: 300,
  useNativeDriver: true, // Only works for transform/opacity
}).start();

Interview Questions & Answers

Q1: How do you optimize FlatList performance for a list with 10,000 items?

Answer:

FlatList already virtualizes, but default settings often cause jank at scale. Apply these optimizations:

  1. keyExtractor — Return a stable unique ID (never use array index for dynamic lists).
  2. getItemLayout — If row height is fixed, provide (data, index) => ({ length, offset, index }) to skip layout measurement.
  3. Memoize row component — Wrap renderItem output in React.memo and pass renderItem via useCallback.
  4. Tune batch propsinitialNumToRender={10}, maxToRenderPerBatch={10}, windowSize={5} balance memory vs scroll smoothness.
  5. removeClippedSubviews — Helps on Android by unmounting off-screen views.

For extremely demanding feeds (social media timelines), consider FlashList from Shopify which recycles native views and provides better scroll performance metrics out of the box.


Q2: When should you use useMemo and useCallback, and when are they unnecessary?

Answer:

Use useMemo when deriving expensive values (filtering/sorting thousands of items, complex calculations) or when you need referential stability for dependency arrays in useEffect.

Use useCallback when passing functions to React.memo-wrapped children or to hooks that compare dependencies by reference.

They are unnecessary when:

  • The computation is trivial (adding two numbers)
  • The child is not memoized and re-renders regardless
  • You memoize everything "just in case" — this adds overhead (comparing deps) without benefit

Rule of thumb: measure first with React DevTools Profiler, then memoize proven bottlenecks.


Q3: What is Hermes and why does React Native use it?

Answer:

Hermes is a JavaScript engine built specifically for React Native mobile apps. Unlike V8/JSC which compile at runtime, Hermes precompiles JavaScript to bytecode at build time.

Key benefits:

  • Faster startup — No JIT warmup delay
  • Lower memory usage — Critical for Android mid-range devices
  • Smaller app size — Bytecode is compact
  • Consistent performance — Predictable execution without JIT deoptimization

Hermes is the default engine in modern React Native (0.70+). It integrates with Metro bundler and supports debugging via Flipper and Chrome DevTools.

Trade-off: Hermes does not support eval() and some obscure JS features, but this rarely affects production React Native apps.


Q4: How do you profile a React Native app to find performance bottlenecks?

Answer:

Use a layered profiling approach:

  1. React DevTools Profiler — Record component render times, identify unnecessary re-renders, check why components updated (props/state/context).

  2. Flipper Performance plugin — Monitor JS and UI thread frame rates during interactions like scrolling.

  3. Xcode Instruments (iOS) / Android Studio Profiler — Native-level CPU, memory, and GPU analysis.

  4. Custom markers — Wrap screens in <Profiler> or use performance.now() to log screen load times.

  5. Production monitoring@shopify/react-native-performance or Sentry performance tracking.

Typical findings: unmemoized FlatList rows, inline functions in lists, large images, synchronous storage reads on mount, and bridge-heavy native module calls.


Q5: What are the best practices for image optimization in React Native?

Answer:

  1. Right-size images — Request CDN variants matching display size; never decode 4000×3000 into a 100×100 thumbnail.
  2. Modern formats — WebP/AVIF over PNG/JPEG where supported.
  3. Caching — Use react-native-fast-image for aggressive disk and memory caching with cache control headers.
  4. Prefetch — Call Image.prefetch(url) for images on the next screen before navigation.
  5. Local assets — Bundle icons and static images; avoid network for UI chrome.
  6. Progressive loading — Show placeholder/blur hash while full image loads.
  7. Memory management — In long image galleries, ensure off-screen images are released (FlashList helps).

Avoid resizeMode changes that trigger relayout on every render, and never store base64 images in JS state for large photos.


Q6: How do you reduce JavaScript bundle size in a React Native app?

Answer:

JavaScript layer:

  • Enable inlineRequires in Metro for lazy module initialization
  • Code-split with dynamic import() for infrequent screens
  • Audit dependencies with bundle visualizer; remove unused packages
  • Replace heavy libs (moment → dayjs, full lodash → per-function imports)
  • Avoid importing entire icon libraries; use selective imports

Native layer:

  • Enable ProGuard/R8 (Android) and strip dead code
  • Review native modules — video, maps, and analytics SDKs add significant binary weight
  • Use App Thinning (iOS) and Android App Bundles (AAB) for device-specific delivery

Process:

  • Set bundle size budgets in CI
  • Compare bundle size on every PR
  • Target incremental loading for features used by minority of users

Q7: Explain the difference between the JS thread and UI thread, and how performance issues manifest on each.

Answer:

React Native has two primary threads:

JavaScript Thread:

  • Runs React reconciliation, business logic, network response handling
  • Blocked by: heavy computation, synchronous AsyncStorage, large JSON parsing, excessive re-renders
  • Symptoms: Delayed touch response, slow navigation transitions, timers firing late

UI (Native) Thread:

  • Handles layout, drawing, gestures, native animations
  • Blocked by: complex view hierarchies, overdraw, non-native-driver animations, heavy native module work on main thread
  • Symptoms: Scroll jank, stuttering animations, frozen gestures

The Bridge (legacy) or JSI (New Architecture) connects them. Excessive cross-thread messaging causes overhead.

Fix strategies:

  • JS thread: memoization, web workers for heavy compute, defer work with InteractionManager
  • UI thread: useNativeDriver: true, simplify view hierarchy, move work off main thread in native code
  • Both: New Architecture reduces serialization overhead via JSI direct access

Use Flipper to see which thread drops frames during specific user interactions.


Q8: What is InteractionManager and when should you use it?

Answer:

InteractionManager is a React Native API that defers JavaScript work until animations and gestures finish, preventing frame drops when the UI thread is busy with transitions.

import { InteractionManager } from 'react-native';
import { useEffect } from 'react';

function ProductScreen() {
  useEffect(() => {
    const task = InteractionManager.runAfterInteractions(() => {
      // Safe to run heavy work: analytics, prefetch, large state updates
      loadRecommendations();
    });
    return () => task.cancel();
  }, []);

  return (/* ... */);
}

When to use:

  • After screen navigation animations complete
  • Before fetching non-critical data on mount
  • Before rendering heavy secondary UI (charts, maps)

When NOT to use:

  • Critical path data needed immediately on screen load (use priority fetch instead)
  • Replacing proper list virtualization or memoization

Interview tip: Contrast with requestAnimationFrame (next frame) and setTimeout (arbitrary delay). InteractionManager waits for the interaction/animation queue to drain — the Toptal/samsoul16 classic question.

Also mention InteractionManager.createInteractionHandle() / clearInteractionHandle() when you need to register custom long-running interactions.


Navigation

Previous: Testing

Next: Animations