Why Your React Native App Is Bigger Than It Should Be
Here's a stat that might sting: every 6 MB increase in app size drops install conversion rates by roughly 1%. On mobile, users expect apps to download in seconds. Apple enforces a 200 MB cellular download limit, and Google Play warns users about apps over 150 MB. If your React Native Expo app weighs in at 35–70 MB when it could easily be 10–20 MB, you're literally leaving installs on the table.
But here's the thing — the problem isn't React Native itself. A bare minimum Expo app weighs under 3 MB on the App Store. The bloat creeps in from universal binaries that bundle every CPU architecture and screen density, unoptimized images, unused dependencies, and JavaScript modules that slip in through transitive imports. We've all been there.
The good news? With the tools and techniques available in Expo SDK 52 and later, you can cut your app size by 30–70% without removing a single feature. This guide walks you through every layer of optimization — from analyzing your bundle to Metro tree shaking, asset compression, and platform-specific build configuration — with practical examples you can apply right now.
Step 1: Analyze Your Bundle Before You Optimize
Optimizing blind is a waste of time. Seriously. Before making any changes, you need to see exactly what's contributing to your app's size. Two tools make this straightforward in 2026.
Expo Atlas — The Built-In Bundle Analyzer
Expo Atlas is purpose-built for React Native and resolves those unmapped regions (sometimes up to 30% of your bundle!) that generic source-map tools just miss. It's available from Expo SDK 51 onward.
Start Atlas with your dev server:
# Start with Atlas enabled
EXPO_UNSTABLE_ATLAS=true npx expo start
# Or analyze a production export
EXPO_UNSTABLE_ATLAS=true npx expo export --platform all
npx expo-atlas .expo/atlas.jsonl
With the dev server running, open Atlas at http://localhost:8081/_expo/atlas or press Shift + M in the terminal to open it from the dev tools plugin menu. For production-accurate sizes, start in production mode:
EXPO_UNSTABLE_ATLAS=true npx expo start --no-dev
Inside Atlas you can:
- Inspect individual module sizes and their percentage of the total bundle
- Trace dependency chains to understand why a module got included
- Hold ⌘ Cmd + Click on a graph node to view transformed source, Babel output, and import/export relationships
- Spot duplicate dependencies and unused code
Early adopters like Backpack reduced their JS bundle size by 38% in under two days using Atlas. Start here — the data tells you exactly where to focus your effort.
react-native-bundle-visualizer (Alternative)
If you're on SDK 50 or below, or just prefer a quick visual overview, the community react-native-bundle-visualizer package works with both React Native CLI and Expo projects:
# For Expo projects (SDK 41+)
npx react-native-bundle-visualizer@latest --expo
# For React Native CLI projects (0.72+)
npx react-native-bundle-visualizer@latest
This generates an interactive treemap highlighting the largest modules. Focus on anything occupying more than 5% of your total bundle — those are where you'll get the best bang for your buck.
Android Studio APK Analyzer
For Android-specific analysis, open your .apk or .aab file in Android Studio via Build → Analyze APK. This breaks down native libraries, resources, and DEX files separately from the JS bundle. It's particularly useful for figuring out whether your bloat lives on the native or JavaScript side.
Step 2: Enable Metro Tree Shaking
Tree shaking eliminates dead code by analyzing import and export statements and removing anything your app never actually uses. Metro's tree shaking has been experimental since Expo SDK 52 and is enabled by default starting with SDK 54. If you're on SDK 52 or 53, you'll need to opt in manually.
Enabling Tree Shaking in metro.config.js
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
});
module.exports = config;
Then set the environment variable before building for production:
EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1 npx expo export --platform all
What Metro Tree Shaking Actually Does
Once enabled, Metro performs several optimizations on production bundles:
- Star export expansion:
export * from './utils'gets expanded and unused exports are removed - Platform shaking: Code guarded by
Platform.OS === 'ios'conditionals is stripped from the other platform's bundle - Dev code removal: Blocks wrapped in
if (__DEV__)orprocess.env.NODE_ENV !== 'production'are eliminated entirely - Recursive optimization: Metro recurses through the dependency graph up to 5 times per module, removing imports that become unused after an initial pass strips their consumers
That recursive part is honestly pretty clever — it catches dead code that simpler single-pass approaches would miss.
Mark Your Code as Side-Effect Free
Tree shaking only works when modules are marked as safe to eliminate. Add the sideEffects field to your package.json:
{
"name": "my-app",
"sideEffects": [
"*.css",
"./src/polyfills.js"
]
}
Files listed in the array are preserved regardless of whether their exports are used. Everything else becomes eligible for removal. If your app has no side-effectful imports, just set "sideEffects": false.
Step 3: Optimize Your Imports
Even without tree shaking, how you write imports has a massive impact on bundle size. The wrong import style can pull in entire libraries when you only need a single function.
Cherry-Pick Instead of Barrel-Importing
// BAD — imports the entire lodash library (~85 KB)
import { debounce } from 'lodash';
// GOOD — imports only the debounce module (~16 KB, 81% smaller)
import debounce from 'lodash/debounce';
// BAD — imports all of date-fns (~198 KB)
import { format } from 'date-fns';
// GOOD — imports only the format function (~30 KB, 85% smaller)
import { format } from 'date-fns/format';
The difference is staggering once you see it in Atlas.
Audit Icon Libraries
Icon libraries are a surprisingly common source of hidden bloat. I've seen cases where Font Awesome was consuming over 6 MB because all icon packs were bundled despite only a handful of icons actually being used. After switching to specific imports, the node_modules contribution dropped from 9.43 MB to 3.12 MB — a 67% reduction.
// BAD — imports all Ionicons
import Ionicons from '@expo/vector-icons/Ionicons';
// BETTER — use only the icon component you need
import { Ionicons } from '@expo/vector-icons';
// BEST — if using few icons, consider react-native-svg
// with individual SVG files to avoid bundling 3,000+ icons
Replace Heavy Libraries with Lighter Alternatives
Run Expo Atlas and identify your top 5 largest node_modules dependencies. For each one, ask: is there a smaller package that does the same thing? Common swaps include:
moment(300 KB+) →date-fns(cherry-picked) ordayjs(2 KB)axios(30 KB) → nativefetch(0 KB, it's already there)lodash(85 KB) →lodash-es(tree-shakable) or individual functionsuuid(12 KB) →expo-cryptorandomUUID()(already bundled with Expo)
Step 4: Optimize Images and Assets
Images typically account for 30–60% of app size when left unoptimized. This is often the single biggest win you can get, and it doesn't require touching any business logic.
Convert to WebP or AVIF
WebP provides 25–35% better compression than PNG at equivalent visual quality, and AVIF pushes that even further. Convert your assets before adding them to the project:
# Convert PNG to WebP using cwebp (install via Homebrew: brew install webp)
cwebp -q 80 icon.png -o icon.webp
# Batch convert all PNGs in assets directory
for f in assets/images/*.png; do
cwebp -q 80 "$f" -o "${f%.png}.webp"
done
React Native supports WebP on both platforms natively. Update your imports to point to the new .webp files and delete the originals.
Compress Remaining Images
For images that must stay as PNG or JPEG (app icons, splash screens, etc.), compress them before committing:
- TinyPNG / TinyJPG: Lossy compression that typically reduces file size by 50–70% with minimal visible quality loss
- ImageOptim (macOS): Lossless optimization that strips metadata and reduces PNG file size by 20–40%
- Squoosh (web tool): Google's tool for comparing compression formats and quality levels side by side
Offload Large Assets to a CDN
Bundling video files, large image galleries, or audio files into your binary is a recipe for bloat. Host them on a CDN instead (CloudFront, Cloudflare R2, Expo Updates) and fetch them at runtime:
import { Image } from 'expo-image';
// Instead of: require('./assets/hero-banner.png')
// Load from CDN:
<Image
source={{ uri: 'https://cdn.example.com/hero-banner.webp' }}
style={{ width: '100%', height: 300 }}
placeholder={{ blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.' }}
contentFit="cover"
transition={200}
/>
The expo-image component handles caching, placeholder rendering, and format negotiation automatically — no extra work on your end.
Audit Fonts
Custom fonts add 50–300 KB each. If you're shipping multiple weights (thin, regular, medium, bold, black) of a font family, remove any weights you don't actually use. Consider using system fonts for body text and limiting custom fonts to headings only.
Step 5: Configure Hermes Bytecode Compilation
Hermes compiles JavaScript to bytecode at build time, so there's no need to parse and compile JS on the device. This reduces the JS bundle size and dramatically improves cold start time. Hermes has been the default engine since React Native 0.70 and is required for the New Architecture.
Verify Hermes is enabled in your app.json:
{
"expo": {
"jsEngine": "hermes"
}
}
If you're migrating from an older project that used JavaScriptCore, switching to Hermes alone can provide a 40–50% reduction in JS bundle size. Bytecode is simply denser than minified JavaScript text.
Hermes Bytecode Diffing for OTA Updates
EAS Update in 2026 supports Hermes bytecode diffing, which sends only the binary differences between old and new bytecode bundles. This reduces OTA update download sizes by up to 75% compared to sending the full bundle. No configuration needed — EAS handles this automatically when both the base build and the update use Hermes.
Step 6: Platform-Specific Build Optimization
Android: Use App Bundles (AAB) Instead of APKs
A universal APK includes native libraries for every CPU architecture (arm64-v8a, armeabi-v7a, x86, x86_64) and resources for every screen density. That's a lot of stuff most users don't need. An Android App Bundle lets Google Play generate optimized APKs per device, delivering only what that specific device requires.
In your eas.json, configure your production profile to build AABs:
{
"build": {
"production": {
"android": {
"buildType": "app-bundle"
}
}
}
}
One team reported their APK dropped from 23 MB to 7.29 MB after switching to AAB — a 70% reduction in download size. Google Play typically reduces AAB download sizes by 30–40% compared to universal APKs. This is probably the single easiest win in this entire guide.
Android: Enable ProGuard / R8 Shrinking
ProGuard (and its successor R8) removes unused Java/Kotlin code and obfuscates the remaining bytecode. Enable it in android/app/build.gradle:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
If you're using Expo managed workflow, R8 shrinking is already enabled in EAS production builds by default. One less thing to worry about.
Android: Split by ABI (Development Only)
If you distribute APKs outside the Play Store (for testing or enterprise distribution), enable ABI splits to generate separate APKs per architecture:
android {
splits {
abi {
enable true
reset()
include 'arm64-v8a', 'armeabi-v7a'
universalApk false
}
}
}
This cuts each APK to roughly half the size of the universal build. For Play Store distribution, just use AAB instead — it handles all of this automatically.
iOS: Strip Unused Architectures
Make sure your release builds target only arm64 (real devices). Simulator architectures (x86_64) shouldn't appear in App Store builds. Xcode handles this by default, but you can verify with:
# Check architectures in your .app binary
lipo -info path/to/YourApp.app/YourApp
Step 7: Remove Development Artifacts from Production
Strip Console Logs
console.log statements add dead weight and can leak information in production. Use babel-plugin-transform-remove-console to strip them at build time:
npm install --save-dev babel-plugin-transform-remove-console
Then add it to your babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};
};
This only runs in production builds, so your development logging stays intact.
Guard Development-Only Code
Wrap any code that should only exist in development with the __DEV__ global:
if (__DEV__) {
// This entire block is removed from production bundles
require('./devtools/ReactQueryDevtools');
}
Metro's tree shaking strips __DEV__ blocks in production, so this costs zero bytes in your release build. It's a nice pattern to get in the habit of using.
Step 8: Monitor Bundle Size in CI
Optimization isn't a one-and-done thing. Without monitoring, bundle size creeps back up with every new dependency or asset someone adds. Set up automated checks in your CI pipeline to catch regressions before they ship.
Using Expo Atlas in CI
# .github/workflows/bundle-check.yml
name: Bundle Size Check
on: [pull_request]
jobs:
check-bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: EXPO_UNSTABLE_ATLAS=true npx expo export --platform android
- name: Check bundle size
run: |
SIZE=$(stat -c%s dist/bundles/android-*.hbc 2>/dev/null || stat -f%z dist/bundles/android-*.hbc)
MAX_SIZE=5242880 # 5 MB threshold
echo "Bundle size: $SIZE bytes"
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
echo "::error::JS bundle exceeds 5 MB threshold ($SIZE bytes)"
exit 1
fi
Set your threshold based on your current bundle size plus a small buffer. Tighten it over time as you optimize. This way, every pull request that increases bundle size beyond the limit gets flagged automatically.
Putting It All Together: Optimization Checklist
So, here's the recommended order of operations — sorted from highest impact to lowest effort:
- Analyze first — Run Expo Atlas and identify the top 5 largest modules
- Switch to AAB — Instant 30–40% Android download size reduction with zero code changes
- Enable Hermes — Verify
jsEngine: "hermes"is set (it's the default in modern Expo) - Optimize images — Convert to WebP, compress, offload large assets to CDN
- Fix imports — Cherry-pick from large libraries, replace heavy packages with lighter alternatives
- Enable tree shaking — Set
experimentalImportSupport: trueand mark packages as side-effect free - Strip console logs — Add
babel-plugin-transform-remove-consolefor production - Set up CI monitoring — Prevent regressions with automated bundle size checks
Following this checklist, most React Native Expo apps can achieve a 30–70% total size reduction. Image-heavy apps benefit the most from asset optimization, while apps with many dependencies see the largest gains from import optimization and tree shaking.
Frequently Asked Questions
Why is my Expo APK so large compared to a native Android app?
The default APK is a universal binary containing native libraries for every CPU architecture (arm64-v8a, armeabi-v7a, x86, x86_64) and resources for every screen density. A bare-minimum Expo app is actually under 3 MB on the App Store — the bloat comes from bundling assets for all device types. Switch to Android App Bundle (AAB) format and Google Play will generate optimized APKs per device, typically reducing download size by 30–40%.
Does tree shaking work with all npm packages?
Not quite. Tree shaking works best with packages that use ECMAScript module syntax (import/export) and declare "sideEffects": false in their package.json. Packages using CommonJS (require/module.exports) are harder to tree-shake because static analysis can't reliably determine which exports are used. If a critical dependency uses CommonJS, cherry-pick individual modules instead of importing the whole package.
How do I check the actual download size users see on the App Store?
The file you build locally (APK or IPA) isn't what users actually download. Apple and Google apply additional compression and optimization on their end. For Android, upload your AAB to Google Play Console and check the "App size" tab for per-device download estimates. For iOS, use Xcode's Organizer window after uploading to App Store Connect — it shows estimated download and install sizes per device class.
Will enabling Hermes break any of my existing JavaScript code?
Hermes supports the vast majority of modern JavaScript (ES2020+) and all React Native APIs. The most common compatibility gotchas are: limited full Intl support (use polyfills or expo-localization), no with statement support, and some differences in Proxy implementation. If you're starting a new Expo project, Hermes is already the default. For migrations, test your app thoroughly and check the Hermes compatibility docs for any edge cases.
How small can a React Native Expo app realistically get?
A minimal Expo app with no added dependencies weighs under 4 MB on the App Store. A production app with typical features (navigation, state management, networking, a few screens) can realistically be kept under 10–15 MB with proper optimization. Apps with many native modules, maps, or media features typically land at 15–25 MB after optimization. Considering the average unoptimized React Native app sits at 35–70 MB, there's significant room for improvement in most projects.