Flutter Obfuscation and Crashlytics Symbols in 2026: 5 Steps to Keep Release Crash Reports Readable
Turning on Dart obfuscation is one flag. Keeping crash reports readable afterwards is the part solo developers skip, and they only notice weeks later when a production crash in Crashlytics shows renamed frames that point nowhere. This guide is the release routine we use for Flutter apps on Google Play: build with obfuscation and split debug info, archive the symbols per version, upload them to Crashlytics before users hit the build, and check that Play Console has what it needs for the Java, Kotlin and native side. Each step names the exact command and the documented limit behind it.
GSPTeck in one line: a one-person studio shipping Flutter and Firebase apps such as CoinDrop, Swappr and IOU on Google Play, and writing up the routines that keep them running. What this routine covers:
- The
flutter buildflags and the symbol files they produce. - The Crashlytics upload command for Android and the version requirements for automatic iOS uploads.
- What Play Console deobfuscates by itself and what it does not.
Honest comparison: you can skip obfuscation entirely and get readable traces with zero setup, at the cost of shipping class and function names in the binary. Obfuscating without a symbol routine is the worst of both: names are hidden from attackers and from you. The routine below is a few commands per release once scripted. Our other apps and tools are listed on the GSPTeck apps homepage.
What obfuscation does and does not do
The Flutter documentation on obfuscating Dart code is explicit about the limits: obfuscation works only on release builds, it renames symbols but does not encrypt resources or prevent reverse engineering, and it is a poor practice to store secrets in an app at all. Web builds are minified instead. Two coding caveats from the same page matter for tests and logging:
- Code that matches class or function names, such as comparing
runtimeType.toString()to a string, fails in an obfuscated binary. - Enum names are currently not obfuscated, so do not rely on obfuscation to hide them.
Search your code for runtimeType and toString() comparisons before the first obfuscated release.
Step 1: build with obfuscation and a per-version symbol folder
Pass both flags to the release build, and point --split-debug-info at a folder named after the version so builds never overwrite each other:
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols/1.4.2+37
The folder then contains one symbols file per target architecture, for example app.android-arm64.symbols. If you also want to read obfuscated names later, add --extra-gen-snapshot-options=--save-obfuscation-map=build/symbols/1.4.2+37/map.json, which writes a JSON map of original and obfuscated names. A side effect worth knowing: moving debug information out of the binary also makes the bundle smaller, which is one of the levers in our Android app size review routine.
Step 2: archive the symbols with the release
Flutter’s docs say to back up the symbols file after each obfuscated build, because without the matching file you cannot de-obfuscate that version’s traces. The symbols must match the exact build: a file from 1.4.2+36 will not decode a crash from 1.4.2+37. We keep three things per version code in private storage:
| File | Why keep it |
|---|---|
app.android-*.symbols | Decode Dart frames for that build |
map.json (optional) | Look up an obfuscated class or function name |
| Release notes with version name and code | Match a crash report to the right folder |
Do not commit symbols to a public repository; they reveal the names obfuscation was meant to hide.
Step 3: upload Dart symbols to Crashlytics before rollout
For Android, the Firebase guide to readable Crashlytics reports for Flutter requires the Firebase CLI version 11.9.0 or later and this command, run from the project root:
firebase crashlytics:symbols:upload \
--app=1:1234567890:android:abc123def456 \
build/symbols/1.4.2+37
Three details from the documentation cause most failed uploads:
--apptakes the Firebase Android App ID (it looks like1:…:android:…), not the package name. Find it in your Firebase project settings or ingoogle-services.json.- The path must be the same folder you passed to
--split-debug-info. - You need to upload the symbols before a crash from that obfuscated build is reported. Run the upload right after the build, before the bundle reaches any track.
If the CLI is not installed on your build machine, the Firebase CLI reference covers installation and non-interactive login for CI.
Step 4: let iOS upload dSYMs automatically, and check it did
On Apple platforms the same Firebase page says symbol upload is automatic when the project uses Flutter 3.12.0 or later with the Crashlytics Flutter plugin 3.3.4 or later; flutterfire configure adds the Xcode run script that finds and uploads dSYM files. Without them, the Crashlytics dashboard shows a “Missing dSYM” alert and holds those exceptions until the files arrive. After your first obfuscated iOS release, open Crashlytics and confirm the alert is absent for the new version.
Step 5: know what Play Console handles on its own
Play Console deobfuscates two kinds of files, according to its help page on deobfuscating or symbolicating crash stack traces: ReTrace-compatible mapping files from R8 or ProGuard for Java and Kotlin code, and native debug symbols. Key facts from that page:
- If you upload an app bundle built with Android Gradle plugin 4.1 or later, Play takes the deobfuscation file from the bundle automatically; APK uploads need a manual upload per version.
- To include native debug symbols in the bundle, set
android.defaultConfig.ndk.debugSymbolLevel = 'FULL'. The symbols file limit is 800 MB; useSYMBOL_TABLEif it is too large. - Only crashes that occur after the file is in place are deobfuscated. Earlier crashes for that version stay obfuscated.
Dart symbols are the Crashlytics side of this routine. For a raw Dart trace from anywhere else, decode it locally with the archived file:
flutter symbolize -i crash.txt \
-d build/symbols/1.4.2+37/app.android-arm64.symbols
Verify with a test crash, then start the rollout
Install the obfuscated release build on a test device from internal testing, force a test exception (the Firebase Flutter setup guide adds a button that runs throw Exception() when a top-level handler calls recordError(..., fatal: true); keep it behind a hidden debug action), reopen the app so the report is sent, and check that the stack trace in Crashlytics shows your real class and method names. Only then promote the build. This fits naturally into our staged rollout rules: readable traces are what make the first rollout stage useful. In the weekly review described in our Crashlytics triage cadence, an unreadable top crash is a sign the upload step was skipped for that version.
FAQ
Does obfuscation make my Flutter app secure?
No. It renames symbols only. Keep secrets on the server, not in the app.
Do I need --obfuscate to use --split-debug-info?
No. --split-debug-info can be used alone to move debug data out of the binary; the symbol upload is still needed for readable Crashlytics traces.
I forgot to upload symbols. Can I fix old crashes?
Upload them now for future reports. Firebase requires symbols before a crash is reported, and Play only deobfuscates crashes after its file is present, so decode older traces locally with flutter symbolize.
Where do I find the Firebase App ID?
In Firebase project settings under your Android app, or in google-services.json as mobilesdk_app_id.
Sources
- Flutter docs: obfuscate Dart code (checked 2026-10-17)
- Firebase: readable Crashlytics reports for Flutter (checked 2026-10-17)
- Firebase: get started with Crashlytics for Flutter (checked 2026-10-17)
- Play Console Help: deobfuscate or symbolicate crash stack traces (checked 2026-10-17)