Flutter Obfuscation and Crashlytics Symbols in 2026: 5 Steps to Keep Release Crash Reports Readable

By giosaia (GSPTeck) · Published 2026-10-17 · Last verified 2026-10-17

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:

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

Two columns on Flutter obfuscation: it renames Dart symbols in release builds and moves debug info out; it does not encrypt resources, stop reverse engineering, protect stored secrets or rename enums
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:

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

Grid of what to keep for each version code: the app.android-arm64.symbols file, the optional map.json, release notes with version name and code, private storage only, exact build match
Keep per version code.

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:

FileWhy keep it
app.android-*.symbolsDecode Dart frames for that build
map.json (optional)Look up an obfuscated class or function name
Release notes with version name and codeMatch 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:

  1. --app takes the Firebase Android App ID (it looks like 1:…:android:…), not the package name. Find it in your Firebase project settings or in google-services.json.
  2. The path must be the same folder you passed to --split-debug-info.
  3. 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

Two columns of where symbols go: Crashlytics needs Firebase CLI 11.9.0 or later, the Firebase App ID and an upload before crashes, with automatic iOS dSYMs on Flutter 3.12 and plugin 3.3.4; Play Console takes R8 mapping and native symbols, automatically from app bundles with AGP 4.1, up to 800 MB
Where symbols go.

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:

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

Four-step release routine: build with obfuscate and split-debug-info, archive symbols per version, run crashlytics symbols upload, verify with a test crash before rollout
The release routine.

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.

Vertical Pinterest graphic listing five release steps for Flutter obfuscation and Crashlytics symbols
Pinterest pin: 5 release steps.

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