Why your Flutter app bundle size grew 40% and what to do about it
A Flutter developer's app starts at 8MB download size. After adding Firebase, a maps package, a rich text editor, and several image assets over six months, it reaches 24MB. The team receives a report from their growth analyst: in markets where users are on metered data connections (Southeast Asia, parts of Africa and Latin America), install conversion has dropped 40%. The app size is the barrier.
This is a predictable outcome for Flutter apps that add packages and assets without monitoring size. Flutter's tooling makes it possible to understand and control bundle size, but it requires intentional measurement — not after the problem is severe, but as a continuous practice.
Where Flutter app size comes from
A Flutter release APK contains several major components:
The Flutter engine. A fixed overhead of approximately 3-5MB for the Dart runtime and Flutter framework. This cannot be reduced below a minimum.
Dart code (app + packages). Your application code and all package dependencies, compiled to native ARM code via AOT compilation. This grows with every package added, especially packages with large dependency trees.
Assets. Images, fonts, audio files, and other assets included in pubspec.yaml. These are included verbatim in the bundle.
Native code. Platform-specific code from packages that include native (Kotlin/Java/Swift/Obj-C) implementations. Firebase, Google Maps, camera packages all include significant native code.
Understanding which component is responsible for a size increase helps target the right reduction technique.
Measuring what is actually in the bundle
flutter build apk --analyze-size generates a detailed size breakdown:
flutter build apk --analyze-size --target-platform android-arm64
# Output: build/flutter_size_01/application.apk.json
# Parse the JSON to see top contributors
dart run devtools_app --connect-to-file build/flutter_size_01/application.apk.json
The output shows which packages contribute to Dart code size, and which assets are largest. Sorting by size reveals the unexpected contributors.
For asset analysis:
# List all assets and their sizes
find assets/ -type f | while read f; do
echo "$(du -sh "$f" | cut -f1) $f"
done | sort -h -r | head -20
Reducing Dart code size
Remove unused packages. The most impactful change. A package that is in pubspec.yaml but not imported anywhere still has its native dependencies pulled in. An imported package where only one small function is used still includes the full package's Dart code.
# Check for packages imported in pubspec.yaml but not used in code
# (Third-party tool — install with: dart pub global activate unused)
dart pub global run unused
Use --split-per-abi to reduce per-platform download size. By default, a Flutter APK includes code for all ARM variants. Splitting by ABI creates separate APKs for each architecture; users download only the one for their device:
flutter build apk --split-per-abi
# Creates:
# build/app/outputs/flutter-apk/app-arm64-v8a-release.apk (~12MB)
# build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk (~10MB)
# build/app/outputs/flutter-apk/app-x86_64-release.apk (~12MB)
# Users download only the APK for their device's CPU
This is the single most effective reduction technique. An app that is 24MB as a universal APK becomes 12-14MB for each architecture-specific APK — immediately solving the download size issue without changing a line of code.
For Play Store distribution, use Android App Bundle instead:
flutter build appbundle
# Google Play delivers only the right architecture to each user
# Effective download size is typically 40-50% of the universal APK
Reducing asset size
Images are typically the largest contributor to asset size in Flutter apps. Three approaches:
Use appropriate formats. PNG for images with transparency; WebP for photos and complex graphics without transparency. WebP typically achieves 25-35% smaller file size than JPEG at equivalent quality.
# pubspec.yaml — include WebP versions of heavy images
flutter:
assets:
- assets/images/onboarding_1.webp # Instead of .jpg
- assets/images/onboarding_2.webp
Resize images to their display size. An image displayed at 300x300 logical pixels on a 3x display requires a 900x900 physical pixel image. Many developers include 2000x2000 images for displays that never render them at that resolution. Use flutter_gen or manual sizing to audit image dimensions against display sizes.
Use lazy loading for images that are not shown immediately. Include a low-resolution placeholder in the bundle; load the full-resolution version from the network. The cached_network_image package makes this straightforward:
// Instead of bundling all high-resolution images:
CachedNetworkImage(
imageUrl: 'https://cdn.example.com/products/${product.id}/hero.webp',
placeholder: (context, url) => Image.asset(
'assets/images/product_placeholder_low_res.webp', // Tiny: 10KB
fit: BoxFit.cover,
),
errorWidget: (context, url, error) => const Icon(Icons.broken_image),
)
Remove unused fonts. Every font file in pubspec.yaml is included in the bundle regardless of whether it is used. Each custom font weight adds 300-500KB.
# Common mistake: including all weights when only two are used
flutter:
fonts:
- family: Inter
fonts:
- asset: fonts/Inter-Regular.ttf # Used
- asset: fonts/Inter-Medium.ttf # Used
- asset: fonts/Inter-SemiBold.ttf # Not used — remove
- asset: fonts/Inter-Bold.ttf # Not used — remove
- asset: fonts/Inter-ExtraBold.ttf # Not used — remove
- asset: fonts/Inter-Italic.ttf # Not used — remove
Reducing native code size
Firebase is a common large contributor. Each Firebase package adds native code:
# Firebase packages and approximate native code contribution
dependencies:
firebase_core: ^2.0.0 # ~500KB native
firebase_auth: ^4.0.0 # ~1.5MB native (includes reCAPTCHA, phone auth)
firebase_firestore: ^4.0.0 # ~2MB native
firebase_storage: ^11.0.0 # ~800KB native
firebase_analytics: ^10.0.0 # ~500KB native
firebase_crashlytics: ^3.0.0 # ~600KB native
# Total: ~6MB native code from Firebase alone
If Firebase Analytics and Crashlytics are not actively monitored, removing them saves over 1MB. If phone authentication is not used, firebase_auth can be replaced with a smaller alternative for email/password only.
Use dynamic feature delivery for rarely-used features. Android's Dynamic Delivery allows large features (a maps screen, a camera feature, a PDF viewer) to be downloaded only when first accessed. This requires some platform-specific setup but can significantly reduce the base download size.
Monitoring size over time
The developer whose app grew from 8MB to 24MB did not notice the growth because there was no size monitoring. CI/CD integration provides the safety net:
# GitHub Actions — size tracking on every PR
- name: Build and measure APK size
run: |
flutter build apk --release --split-per-abi
SIZE=$(du -sk build/app/outputs/flutter-apk/app-arm64-v8a-release.apk | cut -f1)
echo "APK size (arm64): ${SIZE}KB"
# Fail if size exceeds budget
if [ "$SIZE" -gt 15000 ]; then # 15MB budget
echo "APK size ${SIZE}KB exceeds budget of 15000KB"
exit 1
fi
- name: Comment size on PR
uses: actions/github-script@v6
with:
script: |
const size = process.env.APK_SIZE;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `📦 APK size (arm64): ${size}KB`
});
Common mistakes that increase Flutter bundle size unnecessarily
Including debug information in release builds. Flutter release builds exclude most debug information by default, but some packages and Dart build configurations include debug symbols. Run flutter build apk --analyze-size and check whether the vm_snapshot_data or isolate_snapshot_data sections are unexpectedly large. A release build with debug symbols included is significantly larger than necessary.
Bundling multiple resolutions of the same image. Flutter's asset system allows including 1.5x, 2x, and 3x image variants for different pixel density displays. Some developers include all three variants for every image, tripling the image asset size. For most apps, including only the 2x and 3x variants is sufficient — Flutter scales down from 3x for displays between 2x and 3x, and the visual difference from not including 1.5x is negligible on modern devices.
# Instead of including all three variants:
flutter:
assets:
- assets/images/logo.png # 1x
- assets/images/2.0x/logo.png # 2x
- assets/images/3.0x/logo.png # 3x
# Consider only 2x and 3x for most images:
flutter:
assets:
- assets/images/2.0x/logo.png
- assets/images/3.0x/logo.png
Not using deferred loading for large features. Flutter supports deferred loading of Dart code via deferred as imports. Large features that are not used on every launch — an advanced chart view, an admin panel, a rich text editor — can be deferred:
import 'package:my_app/features/charts/charts_screen.dart' deferred as charts;
// Load the deferred library only when needed
Future<void> openChartsScreen() async {
await charts.loadLibrary(); // Downloads the deferred code
navigator.push(MaterialPageRoute(
builder: (_) => charts.ChartsScreen(),
));
}
Deferred loading reduces the initial download size by deferring large feature code to first use.
Including all localization strings regardless of the user's locale. Flutter's intl package generates localization ARB files for each supported locale. If 20 languages are supported, all 20 locale files are included in the bundle. Consider whether all locales are needed in the initial install or whether some can be loaded on demand.
Not compressing large data files bundled as assets. JSON files, database seed files, and other data assets bundled with the app are included uncompressed. Large JSON files (configuration, initial data, lookup tables) can be gzip-compressed and decompressed at first launch, reducing bundle size at the cost of a one-time startup decompression step:
// Bundle data as gzip-compressed file
// In pubspec.yaml: assets: [assets/data/initial_products.json.gz]
Future<List<Product>> loadInitialProducts() async {
final data = await rootBundle.load('assets/data/initial_products.json.gz');
final bytes = data.buffer.asUint8List();
final decompressed = GZipCodec().decode(bytes);
final json = utf8.decode(decompressed);
final list = jsonDecode(json) as List;
return list.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
}
Keeping packages after removing the features that used them. When a feature is removed from the app, the packages it used remain in pubspec.yaml unless explicitly removed. A feature removal task should include explicitly auditing and removing the packages the feature depended on.
Setting a size budget at the project start
The most effective way to prevent bundle size creep is to set a budget at the project start and automate enforcement.
A realistic size budget for a Flutter app:
- Under 10MB download size for the arm64 APK for markets where download size matters
- Under 15MB for markets where download size is less constrained
- Increase the budget by explicit decision only — not by failing to notice creep
The CI check ensures the budget is enforced without manual review:
# In CI, after building:
APK_SIZE_KB=$(du -sk build/app/outputs/flutter-apk/app-arm64-v8a-release.apk | cut -f1)
echo "APK size: ${APK_SIZE_KB}KB"
BUDGET_KB=10240 # 10MB budget
if [ "$APK_SIZE_KB" -gt "$BUDGET_KB" ]; then
echo "ERROR: APK size ${APK_SIZE_KB}KB exceeds budget ${BUDGET_KB}KB"
echo "Review recent package additions and asset changes"
exit 1
fi
A size budget communicated on every PR creates the visibility that makes size creep visible before it becomes a conversion problem. The team whose app grew from 8MB to 24MB without noticing would have caught it at 10MB with this in place — and would have spent 30 minutes removing unused fonts and switching to App Bundle rather than months dealing with declining conversion in growth markets.
Deeper analysis: what the size analyzer reveals
Running flutter build apk --analyze-size produces a JSON file that can be explored programmatically. The output categorizes size contributions into Dart code, native libraries, assets, and Flutter framework overhead. For most apps, two or three packages dominate the Dart code size — and identifying them is the first step toward meaningful reduction.
# Parse the size analysis JSON to find top Dart code contributors
# The output is a tree; leaf nodes are package contributions
cat build/flutter_size_01/application.apk.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
def find_packages(node, path='', results=None):
if results is None:
results = []
name = node.get('n', '')
size = node.get('value', 0)
full_path = f'{path}/{name}' if path else name
if 'children' not in node:
results.append((size, full_path))
else:
for child in node.get('children', []):
find_packages(child, full_path, results)
return results
packages = find_packages(data)
packages.sort(reverse=True)
for size, path in packages[:20]:
print(f'{size:>10,} bytes {path}')
"
A typical output for a medium-sized Flutter app looks like:
2,340,000 bytes dart/package:flutter
1,890,000 bytes dart/package:google_maps_flutter
1,450,000 bytes dart/package:firebase_firestore
890,000 bytes dart/package:flutter_quill
670,000 bytes dart/package:video_player
google_maps_flutter contributing 1.8MB suggests a maps feature that might not be used by all users — a candidate for deferred loading. flutter_quill at 890KB for a rich text editor is expected if the feature is core; if it is behind a feature flag used by 10% of users, deferring it saves 890KB of base download size.
Platform-specific size optimization for iOS
iOS bundle size analysis uses different tooling than Android. The App Store reports download size differently from the .ipa file size, because Apple applies thinning — delivering only the architecture and assets for the user's specific device.
# Analyze iOS app size
flutter build ipa --analyze-size --release
# Creates: build/flutter_size_01/application.ipa.json
# Request App Store Connect app size report
# Settings > App Store Connect API > Run:
xcrun altool --upload-app --type ios -f build/ios/ipa/Runner.ipa \
--apiKey $API_KEY --apiIssuer $ISSUER_ID
# Then check App Store Connect for "Estimated Sizes" after processing
The estimated size from App Store Connect is the most accurate predictor of what users actually download — it accounts for asset thinning, bitcode compilation, and the specific device configuration. The .ipa file size is always larger than what users download.
For iOS, the most impactful size reduction that is iOS-specific is avoiding unnecessary *.xcframework bundles. Some Flutter plugins include fat XCFrameworks with multiple slice types (device, simulator) bundled together. Release builds for the App Store should exclude simulator slices, which some plugin build systems include accidentally.
Tracking size metrics over time with Grafana or similar
Beyond CI enforcement of a size budget, trending size metrics over time identifies growth patterns before they violate the budget. A simple time-series approach using a lightweight metrics store:
# In CI, after building, record size to a metrics file
DATE=$(date +%Y-%m-%d)
SIZE=$(du -sk build/app/outputs/flutter-apk/app-arm64-v8a-release.apk | cut -f1)
COMMIT=$(git rev-parse --short HEAD)
# Append to a CSV file committed to the repo
echo "${DATE},${COMMIT},${SIZE}" >> docs/apk-size-history.csv
git add docs/apk-size-history.csv
git commit -m "chore: update APK size history (${SIZE}KB)"
A CSV file in the repository gives anyone a direct view into the size history with git log or a simple plot. Size increases that correlate with specific commits make the root cause investigation trivial — the commit that added 300KB is immediately visible rather than requiring bisection.
The install conversion calculation
Making the business case for size reduction requires connecting download size to install conversion. The industry benchmark from Google Play data:
- Every 6MB increase in APK size reduces install conversion by approximately 1% in emerging markets
- Users on 2G/3G connections abandon downloads that take more than 30 seconds
For an app at 24MB on a 3G connection (average speed 3Mbps), download time is approximately 64 seconds — well above the 30-second abandonment threshold. Reducing to 12MB brings download time to 32 seconds, still borderline. A 10MB target brings it to 27 seconds, within the acceptable window.
For a product with 10,000 daily install attempts in emerging markets and a 60% current conversion rate: reducing the APK from 24MB to 12MB adds approximately 200 additional daily installs (2 percentage points × 10,000 attempts). Over 12 months, that is 73,000 additional installs — the value of the investment in size reduction, quantified.
Looking ahead: Dart compilation improvements
Flutter's compiler team continues to improve the size efficiency of AOT-compiled Dart code. Tree shaking — the elimination of code that is imported but not called — has become more aggressive in recent releases, reducing the gap between "package imported" and "package fully included." The Dart team's work on smaller SDK footprints and more efficient AOT compilation means that the size baseline for a given set of features is expected to decrease over time. Monitoring size on a consistent SDK version and noting changes across SDK upgrades provides signal on whether the compiler improvements are being realized in the application.