>
E-NO
Expo performance 7 Min Read

Expo Performance Tuning with Practical Examples

calendar_today Published: 2026-08-25
update Last Updated: 2026-08-25
analytics SEO Efficiency: 100%
Technical guide illustration for Expo Performance Tuning with Practical Examples.

Intro

Expo performance tuning can feel like a black box when your app stutters during scroll, drops frames during navigation, or takes too long to start on a low-end Android device. You suspect the problem is in your code, a dependency, or an Expo configuration default, but the path from symptom to verified fix is rarely documented in one place.

This article is a practical guide for developers, DevOps consultants, and technical startup teams who run Expo-powered React Native applications. It connects the common themes of Expo tuning, optimization, latency, and bottlenecks to concrete commands, expected output, failure signals, and recovery decisions. You will not find generic advice like "use production mode" without an example.

Every section follows the same operational pattern: observe before changing, limit the blast radius, use placeholders instead of secrets, verify the result, and document how to recover if the expected state is not reached. This is the difference between guessing and operating.

After reading, you will be able to:

  • Inventory the Expo version and topology that affect performance.
  • Apply safe configuration changes with a tested rollback path.
  • Run verification and diagnostics that produce numeric evidence.
  • Recognize failure modes and recover without a full rebuild.
  • Use a pre-deployment operations checklist to avoid common regressions.

Version and Environment Inventory

Before tuning any Expo application, identify the exact versions and deployment topology. Expo performance is heavily influenced by the version of the Expo SDK, the React Native version, whether you run in Expo Go or a development build, and whether the app runs in a simulator, a physical device, or a production EAS Build.

Start with a read-only observation. Run the following commands in your project root to print the current environment without changing anything:

npx expo --version
npx expo config --type public
npx expo-env-info

Expected output for a project on SDK 50 in January 2025:

5.0.0
{
  "name": "my-app",
  "slug": "my-app",
  "version": "1.0.0",
  "orientation": "portrait",
  "userInterfaceStyle": "light",
  "sdkVersion": "50.0.0",
  "platforms": ["ios", "android", "web"]
}

  expo-env-info 1.2.0 environment info:
    System:
      OS: macOS 14.3
      Shell: 5.9 - /bin/zsh
    Binaries:
      Node: 20.11.0 - ~/.nvm/versions/node/v20.11.0/bin/node
      Yarn: 1.22.19 - /usr/local/bin/yarn
      npm: 10.2.4 - ~/.nvm/versions/node/v20.11.0/bin/npm
      Watchman: 2024.01.22.00 - /usr/local/bin/watchman
    Managers:
      CocoaPods: 1.15.2 - /usr/local/bin/pod
    SDKs:
      iOS SDK:
        Platforms: DriverKit 23.2, iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2
      Android SDK:
        API Levels: 33, 34
        Build Tools: 33.0.2, 34.0.0
        System Images: android-33 | Google APIs ARM 64
    IDEs:
      Android Studio: 2023.1 AI-231.9392.1.2311.11076708
      Xcode: 15.2/15C500b - /usr/bin/xcodebuild
    npmPackages:
      expo: ~50.0.0 => 50.0.0
      react: 18.2.0 => 18.2.0
      react-native: 0.73.2 => 0.73.2
    npmGlobalPackages:
      eas-cli: 7.1.3
    Expo Workflow: managed

Record this output in a file named perf-baseline-YYYY-MM-DD.txt, where YYYY-MM-DD is the date. This baseline is the reference for later comparison. Do not put this file in version control if it contains any internal paths or device identifiers; store it in a local secure notes folder.

Next, identify the deployment topology with read-only checks:

# Is this a managed or bare workflow project?
npx expo config --type introspect | grep -E '"workflow"|"isDetached"'
# Are you using Expo Go or a custom development build?
ls ios/android 2>/dev/null || echo "No native directories; likely Managed with Expo Go"
# Check EAS build profiles if present
eas config

Example output from eas config:

{
  "cli": {
    "version": ">= 7.1.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {}
  }
}

If you see "workflow": "managed" and no ios/ or android/ directories, you are on the Managed workflow. If you have native directories, you are on the Bare workflow. Performance tuning differs: in Managed, many native modules are precompiled and you cannot change some settings; in Bare, you can modify native code but must be more careful with upgrades.

A common performance mistake is testing only on a high-end simulator while users have low-end Android devices. To reveal device-specific bottlenecks, collect a device baseline:

# On a physical Android device connected via USB with USB debugging enabled
adb shell getprop ro.product.model
adb shell getprop ro.build.version.release
adb shell dumpsys meminfo <your.package.name>

Replace <your.package.name> with your actual application ID, for example com.mycompany.myapp. If you do not know the package name on Android, run adb shell pm list packages | grep -i myapp to discover it. If the package name is a production identifier, mask it in your notes.

Expected partial output from dumpsys meminfo:

Applications Memory Usage (in Kilobytes):
Uptime: 12345678 Realtime: 12345678

** MEMINFO in pid 12345 [com.mycompany.myapp] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap    12345    12345        0        0    20480    15000     5000
  Dalvik Heap     6789     6789        0        0    16384    12000     4000
        Stack      512      512        0        0
       Ashmem        0        0        0        0
    Other dev      123      123        0        0
     .so mmap     4321     1000     3000        0
    .apk mmap      123     123        0        0
    .dex mmap     5678      500     4000        0
    .oat mmap      100      100        0        0
    .art mmap     2000     1000      500        0
   Other mmap      300      300        0        0
      Unknown     1000     1000        0        0
        TOTAL    33345    23445     7500        0    36864    27000     9000

Record the TOTAL Pss and Heap Alloc numbers. If the total Pss exceeds 150 MB on a 2 GB RAM device, the app is memory-pressured and may be killed or janky. If you do not have a physical Android device, use an Android emulator with a low RAM profile (for example, create an AVD with 1024 MB RAM and API level 28 to mimic a mid-range device).

Only after this read-only baseline should you consider a change. Every change must be scoped, reversible, and verified. For example, if you decide to upgrade from SDK 49 to SDK 50, run:

npx expo install expo@^50.0.0
npx expo install --fix
npx expo-doctor

The blast radius of an SDK upgrade includes all dependencies and native projects. Have a rollback plan: commit the current state to git, tag it as pre-upgrade-sdk-49, and if the upgrade breaks performance, run git revert or git checkout pre-upgrade-sdk-49 followed by yarn install and npx expo start --clear.

Safe Configuration Path

Expo performance tuning often requires changes to app.json, babel.config.js, or metro.config.js. The safe path is to change one setting at a time, observe the effect, and keep the old value in a diff for reversion.

Start with a common performance setting: JS bundle splitting and production mode. In app.json, you can enable the Hermes JavaScript engine and set the jsEngine explicitly. Hermes is the default since SDK 47, but confirming is useful.

Current recommended block in app.json for SDK 50:

{
  "expo": {
    "name": "My App",
    "slug": "my-app",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "assetBundlePatterns": ["**/*"],
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "com.mycompany.myapp"
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.mycompany.myapp"
    },
    "web": {
      "favicon": "./assets/favicon.png"
    },
    "plugins": [
      ["expo-build-properties", {
        "android": {
          "compileSdkVersion": 34,
          "targetSdkVersion": 34,
          "buildToolsVersion": "34.0.0"
        },
        "ios": {
          "deploymentTarget": "13.4"
        }
      }]
    ],
    "jsEngine": "hermes"
  }
}

Before making this change, check your current app.json with a diff tool. If jsEngine is already "hermes", do not change it. If it is missing, adding it forces Hermes and may improve startup time and memory usage. The blast radius is limited to the JS engine; if you see new crashes, remove the key and rebuild.

A concrete measurement of Hermes impact can be obtained by comparing startup times with and without Hermes. Use the following steps in a development build:

  1. Build once with Hermes enabled ("jsEngine": "hermes").
  2. Measure time to interactive using npx expo start --dev-client and a profiling tool like React Native Debugger or Flipper (if available) or a manual timer.
  3. Change "jsEngine": "jsc" temporarily and rebuild.
  4. Measure again. On a mid-range Android device, Hermes typically reduces cold start from 3.2 seconds to 2.1 seconds and lowers memory by 15 MB.
  5. Revert to the faster engine.

Another Safe Configuration Path is to optimize Metro bundling for development. In metro.config.js, you can enable inline requires and minification for production builds. However, Expo provides defaults that are already tuned. A change that sometimes helps is to reduce the number of modules watched by Metro. In a large monorepo, add:

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

config.watchFolders = [
  // Add only the folders you need
  __dirname + '/src',
  __dirname + '/assets'
];

config.resolver.blockList = [
  /node_modules\/react-native\/.*/__tests__\/.*/,
  /node_modules\/react-native\/.*/__mocks__\/.*/
];

module.exports = config;

This reduces file watching overhead and can improve Metro responsiveness from 200ms to 50ms per rebuild in a monorepo with 10,000 files. To verify the improvement, run npx expo start --clear and observe the Metro bundling time in the terminal output, which prints Bundled 1234 modules in 4321ms. Note the before and after numbers.

Never place real credentials in any config change. For example, if you add an environment variable for API URL, use a placeholder like https://api.example.com and document that it must be replaced. The recovery path for any config change is to remove the added block and restart the bundler.

Verification and Diagnostics

Verification for Expo performance requires numeric evidence. The most useful metrics are:

  • Cold start time: from tapping the app icon to first interactive frame.
  • Warm start time: from background to foreground.
  • Frame rate during a scroll animation.
  • Memory usage during heavy list rendering.
  • JS bundle size and load time.

To measure cold start time programmatically, use the expo-splash-screen and a performance marker in your app code. At the top of your main component, add:

import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';

SplashScreen.preventAutoHideAsync();

export default function App() {
  useEffect(() => {
    const start = Date.now();
    // Simulate initial work
    fetchData().then(() => {
      const end = Date.now();
      console.log(`Time to interactive: ${end - start} ms`);
      SplashScreen.hideAsync();
    });
  }, []);
  // ...
}

This logs the time after your first data fetch. In production, send this metric to your analytics tool. If you do not have analytics, use console.log and capture logs via adb logcat or npx expo start --dev-client and read the terminal output.

To measure JS bundle size, use npx expo export and inspect the output:

npx expo export --platform android --output-dir dist
ls -lh dist/_expo/static/js/android/*.hbc

Expected output:

-rw-r--r--  1 user  staff   1.2M Jan 15 10:30 dist/_expo/static/js/android/App-abc123.hbc

A bundle over 3 MB uncompressed may be a problem. You can run npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/bundle.js and then use source-map-explorer to visualize the bundle:

npx source-map-explorer /tmp/bundle.js

This opens a visualization. Look for large dependencies. For example, if you import lodash entirely, it can add 70 KB; switch to lodash-es with tree shaking or import only the needed function.

For frame rate diagnostics, use the React Native Performance Monitor. In your app, add a temporary overlay:

import { PerformanceMonitor } from 'react-native-performance-monitor';

// In your component
<PerformanceMonitor />

Run a scroll test on a low-end device and watch the FPS counter. If it drops below 55 FPS during a FlatList scroll, you have a render bottleneck. Use the React DevTools Profiler to find which component re-renders excessively. In React Native 0.73 with New Architecture enabled, you can use npx react-native profile or the built-in Performance tab in DevTools.

Verification should always compare before and after. For each change, record a small table:

MetricBefore ChangeAfter ChangeImprovement
Cold start time (ms)3200210034% faster
JS bundle size (MB)2.41.825% smaller
Average FPS during scroll48579 FPS
Memory usage (MB)18015030 MB less

If the expected improvement is not observed, do not keep the change. Revert using your saved diff or git and re-evaluate.

Failure Modes and Recovery

Expo performance tuning can introduce failures. Common failure modes are:

  • App crashes on startup after adding a native module or config change.
  • Infinite reload or bundling errors after editing metro.config.js or babel.config.js.
  • Worse performance (higher memory, lower FPS) after enabling a feature.
  • Build failures on EAS Build due to mismatched SDK versions.

Each failure requires a documented recovery path.

Example 1: Crash on startup after changing jsEngine to Hermes. Recovery path:

  1. Stop the bundler and kill the app on device.
  2. Revert app.json to remove "jsEngine": "hermes" or change it back to "jsc".
  3. Clear caches: npx expo start --clear.
  4. Rebuild the development client: npx expo run:android or npx expo run:ios.
  5. If still crashing, check logs with adb logcat | grep -i error and look for Hermes initialization errors.
  6. If the crash persists, run npx expo-doctor to check for dependency conflicts.

Example 2: Metro bundler errors after adding blockList patterns. Recovery path:

  1. Remove the blockList or watchFolders additions from metro.config.js.
  2. Run npx expo start --clear.
  3. If Metro still fails, delete node_modules and reinstall: rm -rf node_modules && yarn install.
  4. Verify by running npx expo export --platform android --output-dir /tmp/test-export; if that succeeds, Metro config is valid.

Example 3: Increased memory usage after enabling New Architecture. Recovery path:

  1. In app.json, set "newArchEnabled": false if you had enabled it via expo-build-properties.
  2. Rebuild the app.
  3. Measure memory again. If it returns to baseline, the New Architecture may not be ready for your dependencies.
  4. Keep the change out and report the issue to the library maintainers.

Always keep a git tag before making performance changes, for example git tag perf-baseline-before-hermes. Then recovery can be git checkout perf-baseline-before-hermes -- app.json followed by a rebuild. This is faster than manually editing files.

Additionally, simulate a failure before it happens in production. For config changes, run the app in a staging environment first. Use a staging EAS Build profile with different bundle identifiers and no real API keys. Then if the app crashes, it does not affect production users.

Operations Checklist

Use this checklist before and after any Expo performance tuning task. It is designed for a practitioner who needs to avoid missed steps and ensure a verified result.

StepActionCommand or ToolExpected ResultNotes
1Record current environmentnpx expo-env-infoSDK, RN, Node versions listedSave output to baseline file
2Check app config for performance keysOpen app.jsonjsEngine is "hermes" or missingIf missing, consider adding
3Measure current startup timeManual timer or code logExample: 2500 msRun on low-end device
4Measure current bundle sizenpx expo export then ls -lhExample: 2.1 MBNote compression
5Measure baseline FPSReact Native Performance MonitorExample: 52 FPSDuring scroll test
6Identify one performance bottleneckReact DevTools Profiler or source mapLargest component or bundle chunkPrioritize by user impact
7Apply one scoped changeEdit config or codeChange documented in diffKeep old value for revert
8Re-measure affected metricSame as step 3/4/5Compare before/afterIf no improvement, revert
9Run Expo diagnosticsnpx expo-doctorNo errors or warningsFix any issues
10Test on multiple devicesAt least one low-end Android and one iOSNo crashes, acceptable performanceUse device lab if possible
11Document the change and recovery pathWrite in team wiki or git commit messageClear rollback stepsInclude before/after numbers
12Plan for monitoring in productionAdd startup time logging or analyticsMetric collectedSet alert threshold

This checklist can be printed or stored as a Markdown file in the project repo under docs/perf-checklist.md.

A concrete example of a bottleneck identification and fix:

Scenario: A FlatList of 500 items scrolls at 30 FPS on a Samsung A12.

  1. Profile with React DevTools: find that ListItem component re-renders on every scroll due to a new onPress callback created inline.
  2. Fix: use useCallback for the handler or switch to React.memo with proper props.
  3. Re-measure: FPS increases to 55.
  4. Document: in the commit message, note the before/after FPS and the code change.

With this checklist, you reduce the risk of performance regressions and have a clear audit trail.

Conclusion

Expo performance tuning is most useful when each recommendation is version-scoped, observable, and reversible. Copying a command without checking prerequisites and expected output is not an operations procedure; it is a recipe for a broken app.

In this article, you saw how to inventory your environment, make safe configuration changes, verify with concrete metrics, and recover from common failures. The examples used real commands and expected outputs, with placeholders only where necessary, and always included a rollback path.

As a next step, choose one low-risk verification from the Operations Checklist. For example, measure your cold start time on a mid-range Android device using the code snippet in the Verification section. Record the current state in a baseline file, run the measurement, and compare it with the industry expectation of under 3 seconds for a simple app. If your app is slower, identify the largest contributor using source-map-explorer or React DevTools, and apply one scoped fix.

Remember to review dependencies such as React Native, Android build tools, and your CI/CD pipeline (for example, a GitLab CI/CD config that may be using an old Expo image). A reliable technical workflow makes failure visible, protects sensitive values, limits changes to the intended resource, and defines recovery verification before an incident forces the decision.

With this systematic approach, you can turn Expo performance tuning from guesswork into a measurable, repeatable practice that keeps your app responsive and your users happy.

Related Research

Article Quality Score

Reader usefulness 100%
  • check_circle Reader-ready guide
  • check_circle Practical examples included
  • check_circle Clean SEO article URL