Mobile Apps

App size budgets: finding out what actually ships in your bundle

A practical guide to measuring the real payload of your React Native or Flutter app, using tools and CI checks to enforce size budgets.

Mohammed Saqib8 min read
Smartphone displaying a photo editing app, next to a memory card and glasses on a table.
Photo by Leeloo The First on Pexels · Pexels License

A 120 MB APK downloads fast on Wi‑Fi, but on a 4G connection every megabyte past 100 costs you installs. Above 150 MB the conversion drop is measurable; iOS throws a cellular warning at 200 MB, and many users cancel. The problem isn’t just store listing friction. A bloated binary forces the system to spend real time decompressing and mapping Mach‑O segments or DEX files during cold start, especially on low‑end devices. And if you ship OTA updates via CodePush or Expo Updates, the JavaScript bundle size directly determines the payload size—native code changes still require a store submission. Size budgets aren’t a nice‑to‑have; they’re a performance and business metric.

Why app size matters beyond the store listing

Install conversion is the most visible effect, but binary size also shapes runtime behaviour. On Android, the system’s dex2oat compiler processes the DEX at install time; larger APKs increase this step. On iOS, dyld maps the Mach‑O into memory; a bigger binary means more page faults during launch. I’ve seen apps with 180 MB APKs take an extra 800 ms cold start on a Moto G Play compared to a 40 MB build. That difference pushes a “fast enough” app into “annoying” territory.

OTA updates compound the problem. The entire JavaScript bundle is downloaded and applied, even if only a single component changed. If your bundle is 12 MB uncompressed, every hotfix ships 12 MB to every user. With OTA Updates: What Ships Without Another App Review, you avoid the App Store queue, but the user still waits for the download. Keeping the bundle lean reduces that wait.

What contributes to the final binary

Both React Native and Flutter apps pull in more than you explicitly write.

React Native. The Metro bundle contains all your JavaScript, plus the React library, third‑party modules, and assets bundled as base64 or file references. Hermes compiles that bundle into bytecode, which is smaller than raw JavaScript but still accounts for 5–15 MB typically. Native libraries—Folly, Boost, Yoga, and every pod from node_modules—compile into the final binary. Check your Podfile.lock: you’ll see dependencies you never imported directly, pulled in by transitive graph. The same holds for Android AARs.

Flutter. The Dart code is AOT‑compiled into a native snapshot. The engine (Skia or Impeller) adds about 5 MB. Material and Cupertino widget trees are compiled in, even if you only use a handful. Every asset and font in pubspec.yaml lands in the bundle. Like React Native, transitive dependencies from pubspec.lock often include platform‑specific libraries (e.g., path_provider pulls in file and platform).

Both frameworks ship asset catalogs, font files, and configuration plists/manifests that add up. The surprise is usually in the transitive native code.

Tools that tell you what is inside

You can’t trim what you can’t see. Here are the tools I rely on, per platform.

React Native. Start with the Metro bundle:

npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output main.jsbundle

This produces a single JS file. Feed it into source-map-explorer:

npx source-map-explorer main.jsbundle

That opens a treemap of every module and its size. For a more React‑Native‑specific view, react-native-bundle-visualizer wraps the same analysis with a nicer UI.

Flutter. The --analyze-size flag is gold:

flutter build apk --analyze-size

This prints a per‑library and per‑asset breakdown in the console. Combine it with --split-debug-info to strip symbols and see the true code size:

flutter build apk --analyze-size --split-debug-info=debug-info/

Platform‑native analyzers. These show the final binary, not just your code. Android Studio’s APK Analyzer (Build > Analyze APK) shows every entry in the APK with compressed and uncompressed sizes. On iOS, Xcode Organizer’s “Size Report” or Apple’s ios-app-size tool (part of the xcodebuild toolchain) gives per‑file contributions to the .ipa.

Here’s a quick comparison of what each tool covers:

Tool What it measures Misses
source-map-explorer JavaScript modules in Metro bundle Native code, assets, fonts
react-native-bundle-visualizer Same as above, with grouping Same
flutter build apk --analyze-size Dart snapshot, engine, native libs, assets Debug symbols (if not stripped)
APK Analyzer Every file in APK (compressed sizes) Inflated install size (uncompressed)
Xcode Organizer / ios-app-size Every file in .ipa (compressed) Per‑symbol breakdown

Use at least two tools: one for your framework’s bundle, one for the platform binary. The JavaScript bundle might be 8 MB while the APK is 60 MB—you need to know where the other 52 MB lives.

Setting a size budget and enforcing it in CI

A budget is a number you commit to. I define a hard limit per platform: e.g., iOS ≤ 40 MB, Android ≤ 30 MB. Store the current size in a file committed to the repo, like ios-app-size.txt and android-apk-size.txt. In CI, run a build, extract the binary size, and compare.

For a React Native project, you might extract the .app size after xcodebuild:

xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -derivedDataPath build/
SIZE=$(du -sm build/Build/Products/Release-iphoneos/MyApp.app | cut -f1)
echo "SIZE=$SIZE" >> $GITHUB_ENV

Then check against the budget in a script step. Fail the pipeline if the size exceeds the limit or grows more than 5% from the baseline.

For Flutter, parse the output of --analyze-size. Here’s a snippet for GitHub Actions:

- name: Build and analyze size
  run: |
    flutter build apk --analyze-size --target-platform android-arm64
    SIZE=$(grep "Total size" build/app/outputs/flutter-apk/app-release.apk.size.txt | awk '{print $3}')
    echo "APK_SIZE=$SIZE" >> $GITHUB_ENV
- name: Check budget
  run: |
    if [ "$APK_SIZE" -gt 30 ]; then
      echo "APK size $APK_SIZE MB exceeds budget of 30 MB"
      exit 1
    fi

You can also store a baseline and use jq to compare JSON stats from Metro bundler output.

Common bloat sources and how to trim them

Unused icon sets. @expo/vector-icons ships thousands of glyphs; you probably use a few dozen. Replace it with a subset of SVGs or a custom icon font. I’ve seen 2–4 MB savings. For Flutter, flutter_icons generator does the same—generate only the icons you reference.

Duplicate native libraries. In React Native, Folly is bundled by multiple dependencies (e.g., react-native itself, Flipper, react-native-reanimated). Use npx @react-native-community/bundle-diff to detect duplicates. On Android, check app/build.gradle for redundant implementation lines.

Overly broad pubspec.yaml dependencies. Flutter apps often import cupertino_icons and material_design_icons together, shipping both glyph sets. If you only use Material icons, remove the Cupertino dependency. The same applies to platform plugins—only include the ones you actually call.

Large assets. Compress images aggressively. Use WebP on Android (lossy or lossless) and HEIC on iOS if you control the source. For React Native, ensure react-native-image-resizer is part of your build pipeline. For Flutter, flutter_native_splash can generate optimised splash screens.

Platform-specific surprises

iOS. App Store thinning means the user downloads only the slice for their device architecture (arm64 for modern devices). But you still upload a universal binary (or multiple slices) to App Store Connect. That binary counts against your storage quota and affects the download size shown on the store. Disabling Bitcode (deprecated anyway) can shrink the binary if you control build settings, but many third‑party pods still expect it; you’ll need to check each pod’s compatibility. Also, iOS encrypts the Mach‑O during App Store processing, which adds a few megabytes—the size you see in Xcode Organizer may differ from what users actually download.

Android. Multiple APKs per ABI (armeabi‑v7a, arm64‑v8a, x86_64) prevent users from downloading native code they can’t run. Android App Bundle (AAB) handles this automatically; switch from APK to AAB for production. Also, note that the APK Analyzer shows compressed sizes, but the install size (uncompressed) can be 1.5–2× larger. Budget based on compressed size, but track install size as a secondary metric.

Failure modes and limits of size analysis

Size reports from development builds are useless. Debug builds include debug symbols, Hermes inspector, and uncompressed assets. Always analyze a release build with --dev false.

Some tools only show part of the picture. react-native-bundle-visualizer examines only the Metro bundle; it ignores native code. You must combine that with APK Analyzer or Xcode Organizer. Similarly, flutter build apk --analyze-size excludes debug symbols if you use --split-debug-info, but if you forget that flag, the numbers are inflated.

App Store Connect’s reported size often differs from your local .ipa. Apple applies additional compression and encryption during upload. I’ve seen a 35 MB .ipa become 42 MB in the store. Don’t be alarmed; just note the discrepancy and budget based on what you can measure locally.

For a deeper dive into how native code affects cold start, see Cutting cold start time in React Native apps. And if you’re using Expo, Expo EAS Build Profiles That Keep CI Reproducible will help you get consistent size numbers across environments.

Key takeaways

  • Binary size directly impacts install conversion, cold start time, and OTA payload size—treat it as a performance metric.
  • Use at least two analysis tools: one for your framework bundle (Metro or Dart snapshot) and one for the platform binary (APK Analyzer or Xcode Organizer).
  • Enforce a size budget in CI by extracting the binary size from a release build and failing the pipeline on overage.
  • Trim common bloat: replace full icon sets with subsets, remove duplicate native libraries, and compress assets aggressively.
  • Understand platform quirks—App Store thinning, AAB, encryption—so you don’t chase phantom size differences.

Frequently asked questions

Why does my APK grow even though I didn't add any new code?
Likely a transitive native dependency updated in `pubspec.lock` or `Podfile.lock`. Run `flutter pub deps` or check `pod dependencies` to see what pulled in a larger library. Also check if ProGuard/R8 is running—without minification, unused classes remain in the DEX.
Can I trust the size shown by Xcode Organizer for my IPA?
Not directly. Xcode reports the compressed size of the universal binary before App Store thinning. The actual download size on a device is smaller and depends on the device's architecture. Use the 'Estimated App Store Size' in Xcode or check the App Store Connect 'App Size' tab after upload.
Should I use Hermes or JavaScriptCore for smaller React Native apps?
Hermes generally produces smaller bundles because it compiles JavaScript to bytecode ahead of time, stripping the parser and JIT compiler from the binary. However, Hermes adds ~5 MB of native library overhead. For apps under 20 MB JS bundle, benchmark both—the difference is often negligible.
#react-native#flutter#app-size#bundle-analysis#ci
Share

Keep reading