Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Profiling & Monitoring

Module Overview

Estimated Time: 4 hours | Difficulty: Advanced | Prerequisites: Performance Optimization, Debugging modules
Performance problems in mobile apps do not announce themselves — they accumulate silently until users feel the app is “slow” and leave a 2-star review. The challenge is that React Native apps have two execution environments (JavaScript thread and native UI thread), and a bottleneck in either one produces the same visible symptom: dropped frames and sluggish interactions. Profiling is detective work. You need different tools for different suspects: the React Profiler for re-render issues, native profilers (Xcode Instruments, Android Profiler) for native-side bottlenecks, and custom metrics for production monitoring. This module covers them all. What You’ll Learn:
  • React DevTools Profiler
  • Flipper performance plugins
  • Native profiling tools
  • Production monitoring setup
  • Custom performance metrics
  • APM integration

Performance Profiling Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Performance Profiling Stack                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   Development Profiling                 Production Monitoring                │
│   ─────────────────────                 ─────────────────────                │
│                                                                              │
│   ┌─────────────────────┐               ┌─────────────────────┐             │
│   │  React DevTools     │               │  Crash Reporting    │             │
│   │  - Component render │               │  - Sentry           │             │
│   │  - Re-render causes │               │  - Bugsnag          │             │
│   │  - Commit timing    │               │  - Firebase         │             │
│   └─────────────────────┘               └─────────────────────┘             │
│                                                                              │
│   ┌─────────────────────┐               ┌─────────────────────┐             │
│   │  Flipper            │               │  APM Tools          │             │
│   │  - Network          │               │  - New Relic        │             │
│   │  - Layout           │               │  - Datadog          │             │
│   │  - Performance      │               │  - Dynatrace        │             │
│   └─────────────────────┘               └─────────────────────┘             │
│                                                                              │
│   ┌─────────────────────┐               ┌─────────────────────┐             │
│   │  Native Profilers   │               │  Custom Metrics     │             │
│   │  - Xcode Instruments│               │  - App startup      │             │
│   │  - Android Profiler │               │  - Screen load      │             │
│   │  - Systrace         │               │  - API latency      │             │
│   └─────────────────────┘               └─────────────────────┘             │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

React DevTools Profiler

The React Profiler answers the question: “Which components are rendering, how often, and how long does each render take?” This is your first stop when investigating JavaScript-thread performance issues. The key insight: a component re-rendering is not inherently bad — re-rendering unnecessarily (with the same props and state) is where performance is wasted.

Setting Up Profiler

// Enable profiling in development
// The Profiler is available in React DevTools

// Wrap components to profile
import { Profiler, ProfilerOnRenderCallback } from 'react';

const onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.log({
    id,                  // Component name
    phase,               // "mount" or "update"
    actualDuration,      // Time spent rendering
    baseDuration,        // Estimated time without memoization
    startTime,           // When React started rendering
    commitTime,          // When React committed the update
  });
};

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <MainContent />
    </Profiler>
  );
}

Custom Profiler Component

// components/PerformanceProfiler.tsx
import { Profiler, ProfilerOnRenderCallback, ReactNode } from 'react';

interface PerformanceProfilerProps {
  id: string;
  children: ReactNode;
  enabled?: boolean;
  threshold?: number; // Log only if render takes longer than threshold
}

const renderMetrics: Map<string, number[]> = new Map();

export function PerformanceProfiler({
  id,
  children,
  enabled = __DEV__,
  threshold = 16, // 16ms = 60fps
}: PerformanceProfilerProps) {
  const handleRender: ProfilerOnRenderCallback = (
    componentId,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime
  ) => {
    // Store metrics
    const metrics = renderMetrics.get(componentId) || [];
    metrics.push(actualDuration);
    renderMetrics.set(componentId, metrics);

    // Log slow renders
    if (actualDuration > threshold) {
      console.warn(`[Slow Render] ${componentId}`, {
        phase,
        actualDuration: `${actualDuration.toFixed(2)}ms`,
        baseDuration: `${baseDuration.toFixed(2)}ms`,
        improvement: `${((baseDuration - actualDuration) / baseDuration * 100).toFixed(1)}%`,
      });
    }
  };

  if (!enabled) {
    return <>{children}</>;
  }

  return (
    <Profiler id={id} onRender={handleRender}>
      {children}
    </Profiler>
  );
}

// Get profiling statistics
export function getProfilingStats(componentId: string) {
  const metrics = renderMetrics.get(componentId) || [];
  if (metrics.length === 0) return null;

  const sum = metrics.reduce((a, b) => a + b, 0);
  const avg = sum / metrics.length;
  const max = Math.max(...metrics);
  const min = Math.min(...metrics);

  return {
    renderCount: metrics.length,
    averageTime: avg.toFixed(2),
    maxTime: max.toFixed(2),
    minTime: min.toFixed(2),
    totalTime: sum.toFixed(2),
  };
}

Flipper Integration

Setup Flipper

# Flipper is included in React Native CLI projects
# For Expo, use expo-dev-client

npx expo install expo-dev-client

Custom Flipper Plugin

// plugins/PerformancePlugin.ts
import { addPlugin } from 'react-native-flipper';

interface PerformanceEvent {
  type: 'render' | 'api' | 'navigation' | 'custom';
  name: string;
  duration: number;
  timestamp: number;
  metadata?: Record<string, unknown>;
}

let flipperConnection: any = null;

export function initPerformancePlugin() {
  addPlugin({
    getId() {
      return 'react-native-performance';
    },
    onConnect(connection) {
      flipperConnection = connection;
      console.log('Performance plugin connected');
    },
    onDisconnect() {
      flipperConnection = null;
    },
    runInBackground() {
      return true;
    },
  });
}

export function logPerformanceEvent(event: PerformanceEvent) {
  if (flipperConnection) {
    flipperConnection.send('performanceEvent', event);
  }
  
  // Also log to console in development
  if (__DEV__) {
    console.log(`[Performance] ${event.type}: ${event.name} - ${event.duration}ms`);
  }
}

Performance Tracking Hook

// hooks/usePerformanceTracking.ts
import { useEffect, useRef } from 'react';
import { logPerformanceEvent } from '@/plugins/PerformancePlugin';

export function usePerformanceTracking(screenName: string) {
  const startTime = useRef(Date.now());
  const hasLogged = useRef(false);

  useEffect(() => {
    // Track screen mount time
    const mountTime = Date.now() - startTime.current;
    
    if (!hasLogged.current) {
      logPerformanceEvent({
        type: 'navigation',
        name: `${screenName}_mount`,
        duration: mountTime,
        timestamp: Date.now(),
      });
      hasLogged.current = true;
    }

    return () => {
      // Track screen unmount
      logPerformanceEvent({
        type: 'navigation',
        name: `${screenName}_unmount`,
        duration: Date.now() - startTime.current,
        timestamp: Date.now(),
      });
    };
  }, [screenName]);

  // Return function to track custom events
  return (eventName: string, metadata?: Record<string, unknown>) => {
    logPerformanceEvent({
      type: 'custom',
      name: `${screenName}_${eventName}`,
      duration: Date.now() - startTime.current,
      timestamp: Date.now(),
      metadata,
    });
  };
}

Native Profiling Tools

iOS Instruments

// Enable Time Profiler in Xcode
// Product > Profile > Time Profiler

// Add signposts for custom measurements
import os.signpost

let log = OSLog(subsystem: "com.myapp", category: "Performance")

// Start measurement
os_signpost(.begin, log: log, name: "API Call")

// End measurement
os_signpost(.end, log: log, name: "API Call")

Android Profiler

// Enable Android Profiler in Android Studio
// View > Tool Windows > Profiler

// Add trace sections
import android.os.Trace

// Start trace
Trace.beginSection("API Call")

// End trace
Trace.endSection()

Systrace Integration

// Enable Systrace in React Native
// In development, shake device > "Start Systrace"

// Or programmatically
import { Systrace } from 'react-native';

// Start tracing
Systrace.beginEvent('MyCustomEvent');

// End tracing
Systrace.endEvent();

// Async tracing
const cookie = Systrace.beginAsyncEvent('AsyncOperation');
// ... async work
Systrace.endAsyncEvent('AsyncOperation', cookie);

Custom Performance Metrics

Built-in tools show you what is happening during development, but production is where performance actually matters. Users on 3-year-old Android phones with spotty cellular connections experience your app very differently from your development MacBook. Custom metrics bridge this gap by collecting timing data from real user sessions. The pattern below is a lightweight performance tracking service that measures arbitrary operations, buffers the results, and flushes them to your backend after interactions complete (so the metrics collection itself does not cause jank).

Performance Metrics Service

// services/performanceMetrics.ts
import { InteractionManager } from 'react-native';

interface Metric {
  name: string;
  value: number;
  unit: string;
  timestamp: number;
  tags?: Record<string, string>;
}

class PerformanceMetrics {
  private metrics: Metric[] = [];
  private timers: Map<string, number> = new Map();

  // Start a timer
  startTimer(name: string): void {
    this.timers.set(name, performance.now());
  }

  // End timer and record metric
  endTimer(name: string, tags?: Record<string, string>): number {
    const startTime = this.timers.get(name);
    if (!startTime) {
      console.warn(`Timer ${name} not found`);
      return 0;
    }

    const duration = performance.now() - startTime;
    this.timers.delete(name);

    this.recordMetric({
      name,
      value: duration,
      unit: 'ms',
      timestamp: Date.now(),
      tags,
    });

    return duration;
  }

  // Record a metric
  recordMetric(metric: Metric): void {
    this.metrics.push(metric);

    // Send to analytics after interactions complete
    InteractionManager.runAfterInteractions(() => {
      this.flushMetrics();
    });
  }

  // Flush metrics to backend
  private async flushMetrics(): Promise<void> {
    if (this.metrics.length === 0) return;

    const metricsToSend = [...this.metrics];
    this.metrics = [];

    try {
      await fetch('/api/metrics', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ metrics: metricsToSend }),
      });
    } catch (error) {
      // Re-add metrics on failure
      this.metrics = [...metricsToSend, ...this.metrics];
    }
  }

  // Get all recorded metrics
  getMetrics(): Metric[] {
    return [...this.metrics];
  }

  // Clear all metrics
  clearMetrics(): void {
    this.metrics = [];
    this.timers.clear();
  }
}

export const performanceMetrics = new PerformanceMetrics();

App Startup Tracking

// services/startupMetrics.ts
import { performanceMetrics } from './performanceMetrics';

interface StartupMetrics {
  coldStart: number;
  jsLoad: number;
  firstRender: number;
  interactive: number;
}

class StartupTracker {
  private appStartTime: number;
  private metrics: Partial<StartupMetrics> = {};

  constructor() {
    // Record app start time as early as possible
    this.appStartTime = Date.now();
  }

  markJsLoaded(): void {
    this.metrics.jsLoad = Date.now() - this.appStartTime;
    performanceMetrics.recordMetric({
      name: 'startup_js_load',
      value: this.metrics.jsLoad,
      unit: 'ms',
      timestamp: Date.now(),
    });
  }

  markFirstRender(): void {
    this.metrics.firstRender = Date.now() - this.appStartTime;
    performanceMetrics.recordMetric({
      name: 'startup_first_render',
      value: this.metrics.firstRender,
      unit: 'ms',
      timestamp: Date.now(),
    });
  }

  markInteractive(): void {
    this.metrics.interactive = Date.now() - this.appStartTime;
    this.metrics.coldStart = this.metrics.interactive;
    
    performanceMetrics.recordMetric({
      name: 'startup_interactive',
      value: this.metrics.interactive,
      unit: 'ms',
      timestamp: Date.now(),
    });

    console.log('Startup Metrics:', this.metrics);
  }

  getMetrics(): Partial<StartupMetrics> {
    return { ...this.metrics };
  }
}

export const startupTracker = new StartupTracker();

Usage in App Entry

// App.tsx
import { useEffect, useState } from 'react';
import { startupTracker } from '@/services/startupMetrics';

// Mark JS loaded immediately
startupTracker.markJsLoaded();

export default function App() {
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // Mark first render
    startupTracker.markFirstRender();

    // Initialize app
    async function prepare() {
      // Load fonts, fetch initial data, etc.
      await loadResources();
      
      setIsReady(true);
      
      // Mark interactive after state update
      requestAnimationFrame(() => {
        startupTracker.markInteractive();
      });
    }

    prepare();
  }, []);

  if (!isReady) {
    return <SplashScreen />;
  }

  return <MainApp />;
}

APM Integration

Application Performance Monitoring (APM) tools give you a production-grade observability layer: automatic transaction tracing, error correlation, and dashboards that show how your app performs across device types, OS versions, and network conditions. If custom metrics are a thermometer, APM is a full medical workup. For most React Native teams, Sentry is the best starting point — it handles both crash reporting and performance monitoring in a single SDK, with excellent React Native support.

Sentry Performance Monitoring

npx expo install @sentry/react-native
// services/sentry.ts
import * as Sentry from '@sentry/react-native';

export function initSentry() {
  Sentry.init({
    dsn: 'YOUR_SENTRY_DSN',
    
    // Performance monitoring
    tracesSampleRate: 1.0, // 100% in dev, lower in production
    
    // Enable automatic instrumentation
    enableAutoPerformanceTracing: true,
    
    // React Native specific
    enableNative: true,
    enableNativeCrashHandling: true,
    
    // Environment
    environment: __DEV__ ? 'development' : 'production',
    
    // Before send hook
    beforeSend(event) {
      // Filter or modify events
      return event;
    },
  });
}

// Wrap navigation container
export const SentryNavigationContainer = Sentry.wrap(NavigationContainer);

// Manual transaction
export function trackTransaction(name: string, operation: string) {
  const transaction = Sentry.startTransaction({
    name,
    op: operation,
  });

  return {
    finish: () => transaction.finish(),
    setTag: (key: string, value: string) => transaction.setTag(key, value),
    setData: (key: string, value: unknown) => transaction.setData(key, value),
  };
}

Custom Spans

// hooks/useTrackedFetch.ts
import * as Sentry from '@sentry/react-native';

export function useTrackedFetch() {
  return async function trackedFetch<T>(
    url: string,
    options?: RequestInit
  ): Promise<T> {
    const transaction = Sentry.getCurrentHub().getScope()?.getTransaction();
    
    const span = transaction?.startChild({
      op: 'http.client',
      description: `${options?.method || 'GET'} ${url}`,
    });

    try {
      const response = await fetch(url, options);
      
      span?.setHttpStatus(response.status);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      return data as T;
    } catch (error) {
      span?.setStatus('internal_error');
      throw error;
    } finally {
      span?.finish();
    }
  };
}

New Relic Integration

npm install newrelic-react-native-agent
// services/newrelic.ts
import NewRelic from 'newrelic-react-native-agent';

export function initNewRelic() {
  const appToken = Platform.select({
    ios: 'IOS_APP_TOKEN',
    android: 'ANDROID_APP_TOKEN',
  });

  NewRelic.startAgent(appToken);
  
  // Enable features
  NewRelic.setJSAppVersion('1.0.0');
  NewRelic.analyticsEventEnabled(true);
  NewRelic.networkRequestEnabled(true);
  NewRelic.networkErrorRequestEnabled(true);
  NewRelic.httpResponseBodyCaptureEnabled(true);
}

// Track custom events
export function trackEvent(name: string, attributes?: Record<string, unknown>) {
  NewRelic.recordCustomEvent('CustomEvent', name, attributes);
}

// Track breadcrumbs
export function trackBreadcrumb(name: string, attributes?: Record<string, unknown>) {
  NewRelic.recordBreadcrumb(name, attributes);
}

// Track errors
export function trackError(error: Error, attributes?: Record<string, unknown>) {
  NewRelic.recordError(error);
}

Frame Rate Monitoring

Frame rate is the single most important performance metric for mobile apps. Anything below 60 FPS (16.67ms per frame) is perceptible as jank. Drops below 30 FPS feel genuinely broken. The hook below measures frame timing using requestAnimationFrame and classifies frames as “jank” when they exceed the 16.67ms budget. This is a development tool — do not ship it in production builds, as the measurement itself consumes resources. Use APM tools for production frame rate tracking.
// hooks/useFrameRate.ts
import { useEffect, useRef, useState } from 'react';

interface FrameRateStats {
  currentFps: number;
  averageFps: number;
  droppedFrames: number;
  jankCount: number; // Frames taking > 16ms
}

export function useFrameRate(enabled: boolean = __DEV__): FrameRateStats {
  const [stats, setStats] = useState<FrameRateStats>({
    currentFps: 60,
    averageFps: 60,
    droppedFrames: 0,
    jankCount: 0,
  });

  const frameTimesRef = useRef<number[]>([]);
  const lastFrameTimeRef = useRef(performance.now());
  const frameIdRef = useRef<number>();

  useEffect(() => {
    if (!enabled) return;

    const measureFrame = () => {
      const now = performance.now();
      const delta = now - lastFrameTimeRef.current;
      lastFrameTimeRef.current = now;

      frameTimesRef.current.push(delta);

      // Keep last 60 frames
      if (frameTimesRef.current.length > 60) {
        frameTimesRef.current.shift();
      }

      // Calculate stats every 10 frames
      if (frameTimesRef.current.length % 10 === 0) {
        const frameTimes = frameTimesRef.current;
        const avgFrameTime = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
        const currentFps = Math.round(1000 / delta);
        const averageFps = Math.round(1000 / avgFrameTime);
        const jankCount = frameTimes.filter(t => t > 16.67).length;
        const droppedFrames = frameTimes.filter(t => t > 33.33).length;

        setStats({
          currentFps,
          averageFps,
          droppedFrames,
          jankCount,
        });
      }

      frameIdRef.current = requestAnimationFrame(measureFrame);
    };

    frameIdRef.current = requestAnimationFrame(measureFrame);

    return () => {
      if (frameIdRef.current) {
        cancelAnimationFrame(frameIdRef.current);
      }
    };
  }, [enabled]);

  return stats;
}

FPS Monitor Component

// components/FPSMonitor.tsx
import { View, Text, StyleSheet } from 'react-native';
import { useFrameRate } from '@/hooks/useFrameRate';

export function FPSMonitor() {
  const { currentFps, averageFps, jankCount } = useFrameRate(true);

  const getFpsColor = (fps: number) => {
    if (fps >= 55) return '#22c55e'; // Green
    if (fps >= 30) return '#eab308'; // Yellow
    return '#ef4444'; // Red
  };

  if (!__DEV__) return null;

  return (
    <View style={styles.container}>
      <Text style={[styles.fps, { color: getFpsColor(currentFps) }]}>
        {currentFps} FPS
      </Text>
      <Text style={styles.details}>
        Avg: {averageFps} | Jank: {jankCount}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    top: 50,
    right: 10,
    backgroundColor: 'rgba(0, 0, 0, 0.7)',
    padding: 8,
    borderRadius: 8,
    zIndex: 9999,
  },
  fps: {
    fontSize: 16,
    fontWeight: 'bold',
  },
  details: {
    fontSize: 10,
    color: '#9ca3af',
  },
});

Memory Monitoring

// hooks/useMemoryMonitor.ts
import { useEffect, useState } from 'react';
import { NativeModules, Platform } from 'react-native';

interface MemoryInfo {
  usedJSHeap: number;
  totalJSHeap: number;
  usedNativeHeap?: number;
}

export function useMemoryMonitor(intervalMs: number = 5000): MemoryInfo | null {
  const [memoryInfo, setMemoryInfo] = useState<MemoryInfo | null>(null);

  useEffect(() => {
    const getMemoryInfo = async () => {
      try {
        // JavaScript heap (available in Hermes)
        if (global.HermesInternal) {
          const heapInfo = global.HermesInternal.getRuntimeProperties();
          setMemoryInfo({
            usedJSHeap: heapInfo['Heap Size'] || 0,
            totalJSHeap: heapInfo['Heap Size Limit'] || 0,
          });
        }
      } catch (error) {
        console.warn('Memory monitoring not available:', error);
      }
    };

    getMemoryInfo();
    const interval = setInterval(getMemoryInfo, intervalMs);

    return () => clearInterval(interval);
  }, [intervalMs]);

  return memoryInfo;
}

Performance Dashboard

// screens/PerformanceDashboard.tsx
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { useFrameRate } from '@/hooks/useFrameRate';
import { useMemoryMonitor } from '@/hooks/useMemoryMonitor';
import { startupTracker } from '@/services/startupMetrics';
import { performanceMetrics } from '@/services/performanceMetrics';

export function PerformanceDashboard() {
  const frameRate = useFrameRate(true);
  const memory = useMemoryMonitor();
  const startup = startupTracker.getMetrics();
  const metrics = performanceMetrics.getMetrics();

  return (
    <ScrollView style={styles.container}>
      <Text style={styles.title}>Performance Dashboard</Text>

      {/* Frame Rate */}
      <View style={styles.section}>
        <Text style={styles.sectionTitle}>Frame Rate</Text>
        <MetricRow label="Current FPS" value={`${frameRate.currentFps}`} />
        <MetricRow label="Average FPS" value={`${frameRate.averageFps}`} />
        <MetricRow label="Dropped Frames" value={`${frameRate.droppedFrames}`} />
        <MetricRow label="Jank Count" value={`${frameRate.jankCount}`} />
      </View>

      {/* Memory */}
      {memory && (
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>Memory</Text>
          <MetricRow 
            label="JS Heap Used" 
            value={`${(memory.usedJSHeap / 1024 / 1024).toFixed(2)} MB`} 
          />
          <MetricRow 
            label="JS Heap Total" 
            value={`${(memory.totalJSHeap / 1024 / 1024).toFixed(2)} MB`} 
          />
        </View>
      )}

      {/* Startup */}
      <View style={styles.section}>
        <Text style={styles.sectionTitle}>Startup</Text>
        <MetricRow label="JS Load" value={`${startup.jsLoad || 0}ms`} />
        <MetricRow label="First Render" value={`${startup.firstRender || 0}ms`} />
        <MetricRow label="Interactive" value={`${startup.interactive || 0}ms`} />
      </View>

      {/* Recent Metrics */}
      <View style={styles.section}>
        <Text style={styles.sectionTitle}>Recent Metrics</Text>
        {metrics.slice(-10).map((metric, index) => (
          <MetricRow 
            key={index}
            label={metric.name} 
            value={`${metric.value.toFixed(2)}${metric.unit}`} 
          />
        ))}
      </View>
    </ScrollView>
  );
}

function MetricRow({ label, value }: { label: string; value: string }) {
  return (
    <View style={styles.metricRow}>
      <Text style={styles.metricLabel}>{label}</Text>
      <Text style={styles.metricValue}>{value}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f9fafb',
    padding: 16,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 24,
    color: '#111827',
  },
  section: {
    backgroundColor: '#fff',
    borderRadius: 12,
    padding: 16,
    marginBottom: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.05,
    shadowRadius: 2,
    elevation: 1,
  },
  sectionTitle: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 12,
    color: '#374151',
  },
  metricRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingVertical: 8,
    borderBottomWidth: 1,
    borderBottomColor: '#f3f4f6',
  },
  metricLabel: {
    fontSize: 14,
    color: '#6b7280',
  },
  metricValue: {
    fontSize: 14,
    fontWeight: '500',
    color: '#111827',
  },
});

Choosing the Right Profiling Tool

Each tool in the profiling stack answers a different question. Using the wrong tool wastes time — you will see misleading data or miss the actual bottleneck entirely.
ToolWhat It MeasuresWhen to UseLimitations
React DevTools ProfilerComponent render count, render duration, re-render causesJS thread feels slow; lists are sluggish; state changes cause visible lagCannot see native-side issues; dev mode overhead inflates numbers
Flipper (Performance plugin)Network timing, layout hierarchy, custom eventsGeneral debugging; inspecting network waterfall; layout issuesAdds its own overhead; not available in Expo Go
Xcode Instruments (Time Profiler)CPU usage per function on native threadiOS animations stutter; app startup is slow; native module is suspectmacOS only; requires archive/release build for accurate data
Android ProfilerCPU, memory, network, energy on native threadAndroid-specific jank; memory leaks; battery drain investigationRequires Android Studio; emulator profiling is inaccurate
SystraceFrame-by-frame timeline across JS and native threadsNeed to see the bridge; diagnosing cross-thread bottlenecksLow-level; hard to interpret without experience
Custom Performance MetricsAnything you instrument (startup time, screen load, API latency)Production monitoring; tracking real user experience over timeOnly measures what you instrument; adds code complexity
Sentry / APMTransactions, spans, crash correlation, real-user performanceProduction observability; understanding P50/P95 latencies across devicesCost at scale; sampling means you miss some events
Decision framework — where to start:
  1. “The app feels slow” — Start with React DevTools Profiler. Most perceived slowness in React Native comes from unnecessary re-renders on the JS thread.
  2. “Animations are janky” — If the JS thread profiler shows renders completing under 16ms but animations still drop frames, the bottleneck is on the native thread. Switch to Xcode Instruments or Android Profiler.
  3. “Users report slowness but I can’t reproduce it” — You need production metrics. Set up APM (Sentry) and custom performance tracking to see what real users experience on real devices.
  4. “Startup takes too long” — Instrument startup milestones (JS load, first render, interactive). Compare against budgets: cold start under 2 seconds is the target; under 1 second is excellent.
  5. “The app crashes after extended use” — This is likely a memory leak. Use the Android Profiler’s memory timeline or Xcode’s Memory Graph to find retained objects.

Hermes vs. JSC Profiling Differences

If your app runs on Hermes (the default since React Native 0.70 and Expo SDK 48+), profiling behavior differs from JavaScriptCore (JSC) in important ways:
AspectHermesJSC
BytecodePrecompiled at build time — faster startupJIT compiled at runtime — slower startup, potentially faster peak
Heap snapshotsAvailable via HermesInternal.getRuntimeProperties()Not directly accessible from JS
Sampling profilerBuilt-in: HermesInternal.enableSamplingProfiler()Requires external tools
Chrome DevToolsDirect connection via hermes inspectorVia jsc inspector
Memory overheadLower baseline (~3-5 MB typical)Higher baseline (~8-15 MB typical)
Profiling accuracyAccurate even in dev mode (no JIT warm-up artifacts)JIT optimization can make dev profiling misleading
To take a Hermes CPU profile programmatically:
if (global.HermesInternal) {
  // Start sampling profiler
  global.HermesInternal.enableSamplingProfiler();
  
  // ... run the code you want to profile ...
  
  // Stop and save the profile
  global.HermesInternal.disableSamplingProfiler();
  global.HermesInternal.dumpSamplingProfiler('/tmp/hermes-profile.cpuprofile');
  
  // Open in Chrome DevTools: chrome://tracing
}

Performance Budgets

Defining concrete performance budgets turns vague “make it faster” requests into measurable, actionable targets. These budgets should be enforced in CI — not just aspirational numbers in a wiki.
MetricBudget (Good)Budget (Acceptable)Red Flag
Cold start to interactiveUnder 1.5sUnder 3sOver 5s
Screen transitionUnder 300msUnder 500msOver 1s
FlatList scroll FPS60 FPS sustained45+ FPS sustainedBelow 30 FPS
API response renderUnder 200ms (cached)Under 1s (network)Over 3s
JS bundle sizeUnder 2 MBUnder 5 MBOver 10 MB
Memory usage (idle)Under 100 MBUnder 200 MBOver 300 MB
TTI after deep linkUnder 2sUnder 4sOver 6s
Enforcing budgets in CI:
// scripts/check-bundle-size.ts
import { statSync } from 'fs';

const BUNDLE_SIZE_LIMIT = 5 * 1024 * 1024; // 5 MB
const bundlePath = 'android/app/build/generated/assets/react/release/index.android.bundle';

const stats = statSync(bundlePath);
if (stats.size > BUNDLE_SIZE_LIMIT) {
  console.error(
    `Bundle size ${(stats.size / 1024 / 1024).toFixed(2)} MB ` +
    `exceeds limit of ${(BUNDLE_SIZE_LIMIT / 1024 / 1024).toFixed(2)} MB`
  );
  process.exit(1);
}

Common Profiling Pitfalls

Profiling in Development Mode

Development mode enables React’s reconciler warnings, Strict Mode double-renders, and disables most optimizations. Profiling in dev mode will show every component rendering 2x and taking 3-5x longer than production. Always profile release builds. For Expo:
# Build a production-like profile for profiling
npx expo run:ios --configuration Release
npx expo run:android --variant release

The Observer Effect

The act of profiling changes what you measure. Flipper adds network overhead. The React Profiler’s onRender callback adds JS thread work. The FPS monitor hook from this module uses requestAnimationFrame (which itself consumes frame budget). Profile with one tool at a time, and remove profiling instrumentation from production builds.

Misattributing Bridge Overhead

In the classic React Native architecture (pre-New Architecture), every communication between JS and native crosses the bridge asynchronously. This means a “slow render” might actually be a slow bridge crossing — the JS code finished fast, but the native update was queued behind other bridge traffic. With the New Architecture (Fabric + TurboModules), this is less of an issue because communication is synchronous. Check whether your app uses the New Architecture before spending time optimizing bridge traffic.

Memory Leak False Positives

JS heap growing during a session does not always mean a leak. V8 and Hermes both use generational garbage collectors that may delay collection. A true leak is memory that grows monotonically and never decreases even after GC runs. To confirm: force GC (in Chrome DevTools attached to Hermes, click the trash can icon), then check if memory drops. If it does, you had garbage, not a leak.

Best Practices

Profile in Production Mode

Development mode adds overhead - always profile release builds

Sample in Production

Don’t collect 100% of metrics in production - use sampling

Set Performance Budgets

Define acceptable thresholds and alert when exceeded

Monitor Real Users

Synthetic tests don’t capture real-world conditions

Production Sampling Strategy

Collecting metrics from every user session overwhelms your backend and inflates costs. Use tiered sampling:
Metric TypeSample RateRationale
Crash reports100%Every crash matters — never sample these
Startup timing20-50%Enough to detect regressions; high volume data
Screen load times10-20%Statistical significance at scale; does not need every user
Custom business metrics5-10%Supplement with server-side data for full picture
Detailed transaction traces1-5%Expensive to store; use for deep-dive debugging
Adjust these rates upward for smaller user bases. If you have under 10,000 DAU, sample at 100% for everything — the volume is manageable and you cannot afford to miss patterns.

Next Steps

Module 36: Error Handling & Crash Reporting

Learn to handle errors gracefully and report crashes effectively